Skip to content

Commit a2b3f59

Browse files
committed
feat(session): keep transcripts for 365 days by default
Transcripts now default to a 365-day retention and can be changed from Settings > General, where a change previews how many files the new window removes and requires confirmation before anything is deleted. Plans, file history, debug logs, pastes and agent worktrees keep the previous 30-day window instead of inheriting the transcript setting. Also cleans subagent transcripts together with their parent session, and restricts the bulk cleanup endpoint to the desktop process token so an H5 token cannot wipe every transcript in one request.
1 parent efe5cae commit a2b3f59

20 files changed

Lines changed: 1211 additions & 82 deletions

File tree

‎desktop/src/__tests__/generalSettings.test.tsx‎

Lines changed: 281 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ describe('Settings > General tab', () => {
262262
aiRequestTimeoutMs: 120_000,
263263
proxy: { mode: 'direct', url: '' },
264264
},
265+
cleanupPeriodDays: null,
265266
h5Access: {
266267
enabled: false,
267268
token: null,
@@ -333,6 +334,9 @@ describe('Settings > General tab', () => {
333334
setNetwork: vi.fn().mockImplementation(async (network) => {
334335
useSettingsStore.setState({ network })
335336
}),
337+
setCleanupPeriodDays: vi.fn().mockImplementation(async (days: number) => {
338+
useSettingsStore.setState({ cleanupPeriodDays: days })
339+
}),
336340
appMode: {
337341
mode: 'default',
338342
portableDir: null,
@@ -657,6 +661,280 @@ describe('Settings > General tab', () => {
657661
expect(saveButton).not.toBeDisabled()
658662
})
659663

664+
it('previews session cleanup and only deletes after confirmation', async () => {
665+
const cleanupSessions = vi
666+
.spyOn(settingsApi, 'cleanupSessions')
667+
.mockImplementation(async (days, dryRun = false) => ({
668+
ok: true,
669+
days,
670+
dryRun,
671+
files: 7,
672+
errors: 0,
673+
}))
674+
675+
try {
676+
render(<Settings />)
677+
fireEvent.click(screen.getByText('General'))
678+
679+
const input = screen.getByLabelText('Keep session history for')
680+
expect(input).toHaveValue(365)
681+
682+
const section = screen.getByRole('heading', { name: 'Session History' })
683+
.parentElement as HTMLElement
684+
const saveButton = within(section).getByRole('button', { name: 'Save' })
685+
expect(saveButton).toBeDisabled()
686+
687+
fireEvent.change(input, { target: { value: '90' } })
688+
expect(saveButton).not.toBeDisabled()
689+
690+
await act(async () => {
691+
fireEvent.click(saveButton)
692+
})
693+
694+
expect(cleanupSessions).toHaveBeenCalledWith(90, true)
695+
expect(useSettingsStore.getState().setCleanupPeriodDays).not.toHaveBeenCalled()
696+
697+
const dialog = screen.getByRole('dialog', { name: 'Change session history retention?' })
698+
expect(within(dialog).getByText('7 files currently match this cutoff.')).toBeInTheDocument()
699+
expect(
700+
within(dialog).getByText(/Sessions older than 90 days will be permanently deleted/),
701+
).toBeInTheDocument()
702+
703+
await act(async () => {
704+
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete and save' }))
705+
})
706+
707+
expect(useSettingsStore.getState().setCleanupPeriodDays).toHaveBeenCalledWith(90)
708+
expect(cleanupSessions).toHaveBeenLastCalledWith(90)
709+
expect(useSettingsStore.getState().cleanupPeriodDays).toBe(90)
710+
expect(
711+
screen.queryByRole('dialog', { name: 'Change session history retention?' }),
712+
).not.toBeInTheDocument()
713+
expect(
714+
useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1],
715+
).toMatchObject({
716+
type: 'success',
717+
message: 'Retention updated. 7 old session files removed.',
718+
})
719+
} finally {
720+
cleanupSessions.mockRestore()
721+
}
722+
})
723+
724+
it('cancelling the retention confirmation keeps the stored value', async () => {
725+
const cleanupSessions = vi.spyOn(settingsApi, 'cleanupSessions').mockResolvedValue({
726+
ok: true,
727+
days: 0,
728+
dryRun: true,
729+
files: 3,
730+
errors: 0,
731+
})
732+
733+
try {
734+
render(<Settings />)
735+
fireEvent.click(screen.getByText('General'))
736+
737+
const input = screen.getByLabelText('Keep session history for')
738+
fireEvent.change(input, { target: { value: '0' } })
739+
740+
const section = screen.getByRole('heading', { name: 'Session History' })
741+
.parentElement as HTMLElement
742+
await act(async () => {
743+
fireEvent.click(within(section).getByRole('button', { name: 'Save' }))
744+
})
745+
746+
const dialog = screen.getByRole('dialog', { name: 'Change session history retention?' })
747+
expect(
748+
within(dialog).getByText(/stops recording new session content and permanently deletes/),
749+
).toBeInTheDocument()
750+
751+
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
752+
753+
expect(useSettingsStore.getState().setCleanupPeriodDays).not.toHaveBeenCalled()
754+
expect(cleanupSessions).toHaveBeenCalledTimes(1)
755+
expect(input).toHaveValue(0)
756+
} finally {
757+
cleanupSessions.mockRestore()
758+
}
759+
})
760+
761+
it('surfaces a failed cleanup inside the dialog so the user can retry', async () => {
762+
const cleanupSessions = vi
763+
.spyOn(settingsApi, 'cleanupSessions')
764+
.mockResolvedValueOnce({ ok: true, days: 90, dryRun: true, files: 4, errors: 0 })
765+
.mockRejectedValueOnce(new Error('disk offline'))
766+
767+
try {
768+
render(<Settings />)
769+
fireEvent.click(screen.getByText('General'))
770+
fireEvent.change(screen.getByLabelText('Keep session history for'), {
771+
target: { value: '90' },
772+
})
773+
774+
const section = screen.getByRole('heading', { name: 'Session History' })
775+
.parentElement as HTMLElement
776+
await act(async () => {
777+
fireEvent.click(within(section).getByRole('button', { name: 'Save' }))
778+
})
779+
780+
const dialog = screen.getByRole('dialog', { name: 'Change session history retention?' })
781+
await act(async () => {
782+
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete and save' }))
783+
})
784+
785+
// The error must be visible inside the modal (the card behind it is
786+
// covered) and the dialog must stay open so confirming again retries.
787+
expect(within(dialog).getByText('disk offline')).toBeInTheDocument()
788+
expect(
789+
screen.getByRole('dialog', { name: 'Change session history retention?' }),
790+
).toBeInTheDocument()
791+
} finally {
792+
cleanupSessions.mockRestore()
793+
}
794+
})
795+
796+
it('opens the confirmation only after the impact count is known', async () => {
797+
let resolvePreview!: (value: Awaited<ReturnType<typeof settingsApi.cleanupSessions>>) => void
798+
const previewPromise = new Promise<Awaited<ReturnType<typeof settingsApi.cleanupSessions>>>(
799+
(resolve) => {
800+
resolvePreview = resolve
801+
},
802+
)
803+
const cleanupSessions = vi.spyOn(settingsApi, 'cleanupSessions').mockReturnValue(previewPromise)
804+
805+
try {
806+
render(<Settings />)
807+
fireEvent.click(screen.getByText('General'))
808+
fireEvent.change(screen.getByLabelText('Keep session history for'), {
809+
target: { value: '90' },
810+
})
811+
812+
const section = screen.getByRole('heading', { name: 'Session History' })
813+
.parentElement as HTMLElement
814+
const saveButton = within(section).getByRole('button', { name: 'Save' })
815+
fireEvent.click(saveButton)
816+
817+
// While the dry-run is in flight there is nothing to confirm yet.
818+
expect(saveButton).toBeDisabled()
819+
expect(
820+
screen.queryByRole('dialog', { name: 'Change session history retention?' }),
821+
).not.toBeInTheDocument()
822+
823+
await act(async () => {
824+
resolvePreview({ ok: true, days: 90, dryRun: true, files: 5, errors: 0 })
825+
})
826+
827+
const dialog = screen.getByRole('dialog', { name: 'Change session history retention?' })
828+
expect(within(dialog).getByText('5 files currently match this cutoff.')).toBeInTheDocument()
829+
expect(useSettingsStore.getState().setCleanupPeriodDays).not.toHaveBeenCalled()
830+
} finally {
831+
cleanupSessions.mockRestore()
832+
}
833+
})
834+
835+
it('reports files it could not remove instead of a clean success', async () => {
836+
const cleanupSessions = vi
837+
.spyOn(settingsApi, 'cleanupSessions')
838+
.mockImplementation(async (days, dryRun = false) => ({
839+
ok: true,
840+
days,
841+
dryRun,
842+
files: 6,
843+
errors: dryRun ? 0 : 2,
844+
}))
845+
846+
try {
847+
render(<Settings />)
848+
fireEvent.click(screen.getByText('General'))
849+
fireEvent.change(screen.getByLabelText('Keep session history for'), {
850+
target: { value: '90' },
851+
})
852+
853+
const section = screen.getByRole('heading', { name: 'Session History' })
854+
.parentElement as HTMLElement
855+
await act(async () => {
856+
fireEvent.click(within(section).getByRole('button', { name: 'Save' }))
857+
})
858+
859+
const dialog = screen.getByRole('dialog', { name: 'Change session history retention?' })
860+
await act(async () => {
861+
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete and save' }))
862+
})
863+
864+
expect(
865+
useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1],
866+
).toMatchObject({
867+
type: 'success',
868+
message: 'Retention updated. 6 old session files removed, 2 could not be removed.',
869+
})
870+
} finally {
871+
cleanupSessions.mockRestore()
872+
}
873+
})
874+
875+
it('confirms the value the dialog was opened with even if the store moves underneath', async () => {
876+
// A failed save rolls the store back and the effect then resets the input
877+
// behind the modal. The dialog must stay pinned to what the user saw, or
878+
// the retry would silently apply the rolled-back value instead.
879+
const cleanupSessions = vi
880+
.spyOn(settingsApi, 'cleanupSessions')
881+
.mockImplementation(async (days, dryRun = false) => ({
882+
ok: true,
883+
days,
884+
dryRun,
885+
files: 3,
886+
errors: 0,
887+
}))
888+
889+
try {
890+
render(<Settings />)
891+
fireEvent.click(screen.getByText('General'))
892+
fireEvent.change(screen.getByLabelText('Keep session history for'), {
893+
target: { value: '90' },
894+
})
895+
896+
const section = screen.getByRole('heading', { name: 'Session History' })
897+
.parentElement as HTMLElement
898+
await act(async () => {
899+
fireEvent.click(within(section).getByRole('button', { name: 'Save' }))
900+
})
901+
902+
const dialog = screen.getByRole('dialog', { name: 'Change session history retention?' })
903+
expect(
904+
within(dialog).getByText(/Sessions older than 90 days will be permanently deleted/),
905+
).toBeInTheDocument()
906+
907+
await act(async () => {
908+
useSettingsStore.setState({ cleanupPeriodDays: 365 })
909+
})
910+
// Precondition for the regression: the input behind the modal moved.
911+
expect(screen.getByLabelText('Keep session history for')).toHaveValue(365)
912+
913+
await act(async () => {
914+
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete and save' }))
915+
})
916+
917+
expect(useSettingsStore.getState().setCleanupPeriodDays).toHaveBeenLastCalledWith(90)
918+
expect(cleanupSessions).toHaveBeenLastCalledWith(90)
919+
} finally {
920+
cleanupSessions.mockRestore()
921+
}
922+
})
923+
924+
it('hides the retention controls on a touch-H5 phone browser', () => {
925+
document.documentElement.setAttribute('data-touch-h5', 'true')
926+
try {
927+
render(<Settings />)
928+
fireEvent.click(screen.getByText('General'))
929+
930+
expect(
931+
screen.queryByRole('heading', { name: 'Session History' }),
932+
).not.toBeInTheDocument()
933+
} finally {
934+
document.documentElement.removeAttribute('data-touch-h5')
935+
}
936+
})
937+
660938
it('keeps data storage at the bottom of General settings', () => {
661939
render(<Settings />)
662940

@@ -1725,8 +2003,9 @@ describe('Settings > General tab', () => {
17252003
fireEvent.change(screen.getByLabelText('Tavily API key'), {
17262004
target: { value: 'tvly-test-key' },
17272005
})
1728-
const saveButtons = screen.getAllByRole('button', { name: 'Save' })
1729-
fireEvent.click(saveButtons[saveButtons.length - 1]!)
2006+
const webSearchSection = screen.getByRole('heading', { name: 'WebSearch' })
2007+
.parentElement as HTMLElement
2008+
fireEvent.click(within(webSearchSection).getByRole('button', { name: 'Save' }))
17302009

17312010
expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({
17322011
mode: 'tavily',

‎desktop/src/api/settings.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { api } from './client'
2-
import type { OutputStylesResponse, PermissionMode, UserSettings } from '../types/settings'
2+
import type {
3+
OutputStylesResponse,
4+
PermissionMode,
5+
SessionCleanupResult,
6+
UserSettings,
7+
} from '../types/settings'
38

49
export type CliLauncherStatus = {
510
supported: boolean
@@ -52,4 +57,16 @@ export const settingsApi = {
5257
getCliLauncherStatus() {
5358
return api.get<CliLauncherStatus>('/api/settings/cli-launcher')
5459
},
60+
61+
/**
62+
* Delete transcripts older than `days`, or preview how many files that would
63+
* remove when `dryRun` is set. Mirrors `cleanupPeriodDays` semantics: 0 wipes
64+
* every transcript and stops future persistence.
65+
*/
66+
cleanupSessions(days: number, dryRun = false) {
67+
return api.post<SessionCleanupResult>('/api/settings/session-cleanup', {
68+
days,
69+
dryRun,
70+
})
71+
},
5572
}

‎desktop/src/i18n/locales/en.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,24 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
15491549
// Settings > General - Storage
15501550
'settings.general.modeSwitchTitle': 'Switch data storage location?',
15511551
'settings.general.modeSwitchConfirm': 'Save and Restart',
1552+
'settings.general.sessionRetentionTitle': 'Session History',
1553+
'settings.general.sessionRetentionDescription': 'How long transcripts are kept on this device. Lowering the value deletes older sessions right away, so it asks for confirmation first.',
1554+
'settings.general.sessionRetentionLabel': 'Keep session history for',
1555+
'settings.general.sessionRetentionCurrent': 'Current: {days} days',
1556+
'settings.general.sessionRetentionCurrentOff': 'Current: not saved',
1557+
'settings.general.sessionRetentionUnit': 'days',
1558+
'settings.general.sessionRetentionHint': 'Default is 365 days. Setting this to 0 stops recording session content and deletes every existing transcript.',
1559+
'settings.general.sessionRetentionRequired': 'Enter a number of days.',
1560+
'settings.general.sessionRetentionRange': 'Enter a whole number from 0 to {max}.',
1561+
'settings.general.sessionRetentionSave': 'Save',
1562+
'settings.general.sessionRetentionSaved': 'Retention updated. {count} old session files removed.',
1563+
'settings.general.sessionRetentionSavedPartial': 'Retention updated. {count} old session files removed, {errors} could not be removed.',
1564+
'settings.general.sessionRetentionConfirmTitle': 'Change session history retention?',
1565+
'settings.general.sessionRetentionConfirmDelete': 'Sessions older than {days} days will be permanently deleted. This cannot be undone.',
1566+
'settings.general.sessionRetentionConfirmDisable': 'Setting this to 0 stops recording new session content and permanently deletes every existing transcript. This cannot be undone.',
1567+
'settings.general.sessionRetentionPreview': '{count} files currently match this cutoff.',
1568+
'settings.general.sessionRetentionPreviewUnavailable': 'Could not count matching files. You can still confirm.',
1569+
'settings.general.sessionRetentionConfirmAction': 'Delete and save',
15521570
'settings.general.storageTitle': 'Data Storage Location',
15531571
'settings.general.storageDescription': 'Advanced, low-frequency setting. After switching, sessions, Skills, MCP, plugins, provider settings, tasks, and caches are read from the new directory.',
15541572
'settings.general.storageSystemTitle': 'Use system directory',

‎desktop/src/i18n/locales/jp.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,6 +1551,24 @@ export const jp: Record<TranslationKey, string> = {
15511551
// Settings > General - Storage
15521552
'settings.general.modeSwitchTitle': 'データ保存場所を切り替えますか?',
15531553
'settings.general.modeSwitchConfirm': '保存して再起動',
1554+
'settings.general.sessionRetentionTitle': 'セッション履歴',
1555+
'settings.general.sessionRetentionDescription': 'セッション記録をこの端末に保持する期間です。値を小さくすると古い記録がすぐに削除されるため、確認を求めます。',
1556+
'settings.general.sessionRetentionLabel': 'セッション履歴の保持期間',
1557+
'settings.general.sessionRetentionCurrent': '現在:{days} 日',
1558+
'settings.general.sessionRetentionCurrentOff': '現在:記録を保存しない',
1559+
'settings.general.sessionRetentionUnit': '日',
1560+
'settings.general.sessionRetentionHint': '既定は 365 日です。0 にするとセッション内容を記録しなくなり、既存の記録もすべて削除します。',
1561+
'settings.general.sessionRetentionRequired': '日数を入力してください。',
1562+
'settings.general.sessionRetentionRange': '0 から {max} までの整数を入力してください。',
1563+
'settings.general.sessionRetentionSave': '保存',
1564+
'settings.general.sessionRetentionSaved': '保持期間を更新し、期限切れのセッションファイルを {count} 件削除しました。',
1565+
'settings.general.sessionRetentionSavedPartial': '保持期間を更新しました。期限切れのセッションファイルを {count} 件削除し、{errors} 件は削除できませんでした。',
1566+
'settings.general.sessionRetentionConfirmTitle': 'セッション履歴の保持期間を変更しますか?',
1567+
'settings.general.sessionRetentionConfirmDelete': '{days} 日より古いセッション記録を完全に削除します。元に戻せません。',
1568+
'settings.general.sessionRetentionConfirmDisable': '0 にすると新しいセッション内容を記録しなくなり、既存の記録もすべて完全に削除します。元に戻せません。',
1569+
'settings.general.sessionRetentionPreview': 'この設定では現在 {count} 件のファイルが削除されます。',
1570+
'settings.general.sessionRetentionPreviewUnavailable': '集計に失敗しました。そのまま続行できます。',
1571+
'settings.general.sessionRetentionConfirmAction': '削除して保存',
15541572
'settings.general.storageTitle': 'データ保存場所',
15551573
'settings.general.storageDescription': '高度で使用頻度の低い設定です。切り替え後は、セッション、スキル、MCP、プラグイン、プロバイダー設定、タスク、キャッシュが新しいディレクトリから読み込まれます。',
15561574
'settings.general.storageSystemTitle': 'システムディレクトリを使用',

0 commit comments

Comments
 (0)