Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/restore-archived-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add server APIs to restore archived sessions and list only archived sessions.
5 changes: 5 additions & 0 deletions .changeset/web-archived-sessions-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

web: Add an Archived sessions page in Settings to browse and restore archived sessions. Open Settings → Archived to find it.
12 changes: 12 additions & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
status?: AppSessionStatus;
workspaceId?: string;
includeArchive?: boolean;
archivedOnly?: boolean;
excludeEmpty?: boolean;
},
): Promise<Page<AppSession>> {
Expand All @@ -302,6 +303,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
page_size: input?.pageSize,
status: input?.status ? toWireSessionStatus(input.status) : undefined,
include_archive: input?.includeArchive,
archived_only: input?.archivedOnly,
exclude_empty: input?.excludeEmpty,
// PRESUMED — daemon supports ?workspace_id= once the registry ships; it
// ignores unknown query params until then, so this is safe to always send.
Expand Down Expand Up @@ -420,6 +422,16 @@ export class DaemonKimiWebApi implements KimiWebApi {
return data;
}

// POST /sessions/{id}:restore — clear the archived flag. The daemon returns
// the full restored session, so callers can merge it straight back into lists.
async restoreSession(sessionId: string): Promise<AppSession> {
const data = await this.http.post<WireSession>(
`/sessions/${encodeURIComponent(sessionId)}:restore`,
{},
);
return toAppSession(data);
}

// -------------------------------------------------------------------------
// Messages
// -------------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,14 +645,15 @@ export interface AppSessionWarning {
export interface KimiWebApi {
getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>;
getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record<string, boolean>; openInApps: string[]; dangerousBypassAuth: boolean }>;
listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; excludeEmpty?: boolean }): Promise<Page<AppSession>>;
listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; archivedOnly?: boolean; excludeEmpty?: boolean }): Promise<Page<AppSession>>;
createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise<AppSession>;
/** Fetch one session by id (deep links beyond the first listSessions page). */
getSession(sessionId: string): Promise<AppSession>;
updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise<AppSession>;
getSessionStatus(sessionId: string): Promise<AppSessionRuntimeStatus>;
getSessionWarnings(sessionId: string): Promise<AppSessionWarning[]>;
archiveSession(sessionId: string): Promise<{ archived: true }>;
restoreSession(sessionId: string): Promise<AppSession>;
listMessages(sessionId: string, input?: PageRequest & { role?: AppMessageRole }): Promise<Page<AppMessage>>;
/** v2 initial sync: atomic session state + `asOfSeq` watermark + epoch. */
getSessionSnapshot(sessionId: string): Promise<AppSessionSnapshot>;
Expand Down
204 changes: 202 additions & 2 deletions apps/kimi-web/src/components/settings/SettingsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
scattered in the sidebar account popover: appearance, language, account,
connection, plus notifications and the troubleshooting-log export. -->
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useKimiWebClient } from '../../composables/useKimiWebClient';
import type { AppSession } from '../../api/types';
import { useDialogFocus } from '../../composables/useDialogFocus';
import LanguageSwitcher from './LanguageSwitcher.vue';
import { serverEndpointLabel } from '../../api/config';
Expand Down Expand Up @@ -62,7 +64,7 @@ const emit = defineEmits<{
close: [];
}>();

type SettingsTab = 'general' | 'agent' | 'account' | 'advanced';
type SettingsTab = 'general' | 'agent' | 'account' | 'advanced' | 'archived';

const activeTab = ref<SettingsTab>('general');

Expand All @@ -71,6 +73,7 @@ const tabs: { id: SettingsTab; labelKey: string }[] = [
{ id: 'agent', labelKey: 'settings.tabs.agent' },
{ id: 'account', labelKey: 'settings.tabs.account' },
{ id: 'advanced', labelKey: 'settings.tabs.advanced' },
{ id: 'archived', labelKey: 'settings.tabs.archived' },
];

const daemonEndpoint = serverEndpointLabel();
Expand Down Expand Up @@ -206,6 +209,107 @@ function toggleTelemetry(): void {
function setTab(tab: SettingsTab): void {
activeTab.value = tab;
}

// ---------------------------------------------------------------------------
// Archived-sessions tab — its own list state (server-side `archived_only`
// filter), kept separate from the per-workspace active list. Search, workspace
// filter and sort all run client-side over the loaded pages. Restore goes
// through the composable so the sidebar list updates automatically.
// ---------------------------------------------------------------------------
const client = useKimiWebClient();

const archivedItems = ref<AppSession[]>([]);
const archivedLoading = ref(false);
const archivedLoaded = ref(false);
const archiveQuery = ref('');
const archiveWsFilter = ref<string>('all'); // 'all' | cwd
const archiveSort = ref<'archived-desc' | 'created-desc' | 'name-asc'>('archived-desc');

// Load every archived session once when the tab opens (no frontend pagination).
// Search, sort and the workspace filter then run client-side over the full set,
// so results are always global and there is no empty-page / cursor bookkeeping
// to get wrong. The user waits a moment on first open in exchange for simplicity.
const ARCHIVED_PAGE_SIZE = 100;

async function loadAllArchived(): Promise<void> {
if (archivedLoading.value || archivedLoaded.value) return;
archivedLoading.value = true;
try {
const all: AppSession[] = [];
let beforeId: string | undefined;
for (;;) {
const page = await client.loadArchivedSessions({ beforeId, pageSize: ARCHIVED_PAGE_SIZE });
all.push(...page.items);
if (!page.hasMore || page.items.length === 0) break;
const next = page.items.at(-1)?.id;
if (next === undefined) break;
beforeId = next;
}
archivedItems.value = all;
archivedLoaded.value = true;
} catch (err) {
console.warn('loadAllArchived failed', err);
} finally {
archivedLoading.value = false;
}
}

watch(activeTab, (tab) => {
if (tab === 'archived' && !archivedLoaded.value) {
void loadAllArchived();
}
});

const archiveWorkspaces = computed<string[]>(() => {
const set = new Set<string>();
for (const s of archivedItems.value) set.add(s.cwd);
return Array.from(set).sort((a, b) => a.localeCompare(b));
});

const filteredArchived = computed<AppSession[]>(() => {
const q = archiveQuery.value.trim().toLowerCase();
// Defensive invariant: this panel must only ever render archived sessions,
// even if an older server ignores `archived_only` and falls back to the
// default (unarchived) list. Filter again on the client.
let rows = archivedItems.value.filter((s) => s.archived === true);
if (archiveWsFilter.value !== 'all') {
rows = rows.filter((s) => s.cwd === archiveWsFilter.value);
}
if (q) rows = rows.filter((s) => s.title.toLowerCase().includes(q));
rows = rows.slice();
if (archiveSort.value === 'archived-desc') {
rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} else if (archiveSort.value === 'created-desc') {
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
} else {
rows.sort((a, b) => a.title.localeCompare(b.title, 'zh'));
}
return rows;
});

const groupedArchived = computed<{ cwd: string; items: AppSession[] }[]>(() => {
const map = new Map<string, AppSession[]>();
for (const s of filteredArchived.value) {
const list = map.get(s.cwd) ?? [];
list.push(s);
map.set(s.cwd, list);
}
return Array.from(map.entries()).map(([cwd, items]) => ({ cwd, items }));
});

async function onRestore(id: string): Promise<void> {
const ok = await client.restoreSession(id);
if (ok) {
archivedItems.value = archivedItems.value.filter((s) => s.id !== id);
}
}

function archiveTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number): string => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
</script>

<template>
Expand Down Expand Up @@ -467,6 +571,69 @@ function setTab(tab: SettingsTab): void {
</section>
</section>

<!-- Archived sessions -->
<section v-show="activeTab === 'archived'" class="panel">
<div class="panel-head">
<div class="panel-kicker">Archived sessions</div>
<h4 class="panel-title">{{ t('settings.archivedTitle') }}</h4>
<p class="panel-desc">{{ t('settings.archivedDesc') }}</p>
</div>

<div class="archive-toolbar">
<label class="archive-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input v-model="archiveQuery" :placeholder="t('settings.archivedSearch')" />
</label>
<Select
:model-value="archiveWsFilter"
size="sm"
:aria-label="t('settings.archivedAllWorkspaces')"
@update:model-value="archiveWsFilter = $event as string"
>
<option value="all">{{ t('settings.archivedAllWorkspaces') }}</option>
<option v-for="ws in archiveWorkspaces" :key="ws" :value="ws">{{ ws }}</option>
</Select>
<SegmentedControl
size="sm"
:model-value="archiveSort"
:options="[
{ value: 'archived-desc', label: t('settings.archivedSortArchived') },
{ value: 'created-desc', label: t('settings.archivedSortCreated') },
{ value: 'name-asc', label: t('settings.archivedSortName') },
]"
@update:model-value="archiveSort = $event as 'archived-desc' | 'created-desc' | 'name-asc'"
/>
</div>

<div v-if="archivedLoading" class="archive-empty">
{{ t('settings.archivedLoadingAll') }}
</div>

<template v-else>
<div v-if="groupedArchived.length > 0" class="archive-list">
<section v-for="g in groupedArchived" :key="g.cwd" class="archive-card">
<div class="archive-workspace">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h6l2 2h10v9H3z" /><path d="M3 7V5h6l2 2" /></svg>
<span class="path">{{ g.cwd }}</span>
<span class="count">{{ t('settings.archivedSessionsCount', { count: g.items.length }) }}</span>
</div>
<div class="setting-card">
<div v-for="s in g.items" :key="s.id" class="archive-row">
<div class="archive-meta">
<div class="archive-name">{{ s.title }}</div>
<div class="archive-time">{{ t('settings.archivedAt', { time: archiveTime(s.updatedAt) }) }}</div>
</div>
<Button variant="secondary" size="sm" @click="onRestore(s.id)">{{ t('settings.archivedRestore') }}</Button>
</div>
</div>
</section>
</div>
<div v-else class="archive-empty">
{{ archivedItems.length === 0 ? t('settings.archivedEmpty') : t('settings.archivedNoMatch') }}
</div>
</template>
</section>

</div>
</div>
</Dialog>
Expand Down Expand Up @@ -615,4 +782,37 @@ function setTab(tab: SettingsTab): void {
max-width: none;
}
}
/* Archived-sessions tab */
.setting-card { border: 1px solid var(--color-line); border-radius: var(--radius-xl); overflow: hidden; background: var(--color-bg); }
.panel-head { margin-bottom: var(--space-4); }
.panel-kicker { font-size: var(--text-xs); letter-spacing: 0.05em; text-transform: uppercase; color: var(--color-text-faint); margin-bottom: var(--space-1); }
.panel-title { margin: 0 0 var(--space-2); font-family: var(--font-ui); font-size: var(--text-2xl); font-weight: var(--weight-semibold); letter-spacing: -0.01em; color: var(--color-text); }
.panel-desc { margin: 0; font-family: var(--font-ui); font-size: var(--text-sm); line-height: var(--leading-normal); color: var(--color-text-muted); max-width: 560px; }
.archive-toolbar { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-4); flex-wrap: wrap; }
.archive-search { flex: 1; min-width: 200px; height: 36px; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border-radius: var(--radius-md); border: 1px solid var(--color-line); color: var(--color-text-faint); font-size: var(--text-sm); background: var(--color-surface-raised); transition: border-color var(--duration-fast) var(--ease-out), box-shadow var(--duration-fast) var(--ease-out); }
.archive-search:focus-within { border-color: var(--color-accent); box-shadow: var(--p-focus-ring); color: var(--color-text-muted); }
.archive-search svg { width: 15px; height: 15px; flex: none; }
.archive-search input { width: 100%; border: none; outline: none; background: transparent; font: inherit; color: var(--color-text); }
.archive-list { display: flex; flex-direction: column; gap: var(--space-4); }
.archive-card .setting-card { margin-bottom: 0; }
.archive-workspace { display: flex; align-items: center; gap: var(--space-2); margin: 0 2px var(--space-2); color: var(--color-text-muted); font-size: var(--text-sm); font-weight: var(--weight-medium); }
.archive-workspace svg { width: 16px; height: 16px; color: var(--color-text-faint); flex: none; }
.archive-workspace .path { font-family: var(--font-mono); font-size: var(--text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.archive-workspace .count { margin-left: auto; color: var(--color-text-faint); font-weight: var(--weight-regular); font-size: var(--text-xs); flex: none; }
.archive-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: var(--space-3) var(--space-4); border-top: 1px solid var(--color-line); }
.archive-row:first-child { border-top: none; }
.archive-row:hover { background: var(--color-surface-sunken); }
.archive-meta { min-width: 0; }
.archive-name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.archive-time { margin-top: 2px; font-size: var(--text-xs); color: var(--color-text-faint); font-family: var(--font-mono); }
.archive-draining { margin-bottom: var(--space-3); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); background: var(--color-accent-soft); color: var(--color-accent-hover); font-size: var(--text-sm); }
.archive-empty { padding: var(--space-6) var(--space-4); border: 1px solid var(--color-line); border-radius: var(--radius-xl); color: var(--color-text-faint); font-size: var(--text-sm); text-align: center; background: var(--color-bg); }
@media (max-width: 640px) {
.archive-toolbar { flex-direction: column; align-items: stretch; }
.archive-search { min-width: 0; }
}
/* Enlarge the settings frame a bit (Dialog `xl` = 760px wide, fixed-height
680px). Scoped to this dialog only. */
:deep(.ui-dialog) { width: min(980px, 96vw); }
:deep(.ui-dialog--fixed-height) { height: min(780px, calc(100vh - var(--space-8) * 2)); }
</style>
26 changes: 26 additions & 0 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1694,6 +1694,30 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
}
}

/** Restore an archived session — calls API, then puts the returned session
* back at the front of the list so it reappears in the sidebar. */
async function restoreSession(id: string): Promise<boolean> {
try {
const restored = await getKimiWebApi().restoreSession(id);
upsertSessionFront(restored);
return true;
} catch (err) {
pushOperationFailure('restoreSession', err, { sessionId: id });
return false;
}
}

/** List archived sessions (server-side `archived_only` filter). Kept separate
* from the per-workspace active list — callers (e.g. Settings) hold the page
* locally and do their own search/filter/sort. */
function loadArchivedSessions(input?: { beforeId?: string; pageSize?: number }) {
return getKimiWebApi().listSessions({
archivedOnly: true,
beforeId: input?.beforeId,
pageSize: input?.pageSize ?? 50,
});
}

/** Logout from the managed Kimi provider. Re-checks auth and reloads sessions. */
async function logout(): Promise<void> {
try {
Expand Down Expand Up @@ -2007,6 +2031,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
renameWorkspace,
deleteWorkspace,
archiveSession,
restoreSession,
loadArchivedSessions,
logout,
compact,
forkSession,
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2522,6 +2522,8 @@ export function useKimiWebClient() {
reorderWorkspaces,
setWorkspaceSortMode,
archiveSession: workspaceState.archiveSession,
restoreSession: workspaceState.restoreSession,
loadArchivedSessions: workspaceState.loadArchivedSessions,
compact: workspaceState.compact,
forkSession: workspaceState.forkSession,
undo: workspaceState.undo,
Expand Down
17 changes: 17 additions & 0 deletions apps/kimi-web/src/i18n/locales/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default {
agent: 'Agent',
account: 'Account',
advanced: 'Advanced',
archived: 'Archived',
},
appearance: 'Appearance',
notifications: 'Notifications',
Expand Down Expand Up @@ -47,4 +48,20 @@ export default {
exportLogBtn: 'Export log',
conversationToc: 'Show conversation outline',
conversationTocHint: 'Show a clickable outline in the right margin to jump between messages',
archivedTitle: 'Archived sessions',
archivedDesc: 'Browse archived sessions, see their workspace path, name, and archive time, and restore them to the session list.',
archivedSearch: 'Search archived sessions',
archivedAllWorkspaces: 'All workspaces',
archivedSortLabel: 'Sort by',
archivedSortArchived: 'Archive time',
archivedSortCreated: 'Created time',
archivedSortName: 'Name',
archivedRestore: 'Restore',
archivedEmpty: 'No archived sessions yet',
archivedNoMatch: 'No matching archived sessions',
archivedSessionsCount: '{count} sessions',
archivedAt: 'Archived {time}',
archivedLoadMore: 'Load more',
archivedLoading: 'Loading…',
archivedLoadingAll: 'Loading all archived sessions…',
};
Loading
Loading