/** * Left sidebar — a single-column workspace → session tree backed by the v2 * list's grouped projection (`GET /api/v2/sessions?view=by_workspace`, see * `src/sessions/api.ts`): one request returns every workspace with a matching * session, each carrying its first `group.page_size` sessions under the * requested sort plus the workspace's full matching total. Preset views * (`src/sessions/views.ts`) map onto the endpoint's status / archived / git * query conditions; the active view, collapsed workspaces, and the panel * width persist to localStorage. Group pagination is the endpoint's opaque * cursor (Load more), with a slow poll on top; a workspace whose total * outruns the served slice expands inline into the flat per-workspace * listing. Live activity frames (`useSessionActivities`) override the REST * status badge. Session creation still goes through the v1 REST endpoint. */ import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager'; import { IWorkspaceService, type Workspace, } from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; import { IModelCatalog } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; import type { SessionWorkFacts } from '../activity/store'; import { useSessionActivities } from '../activity/useSessionActivity'; import type { InspectClient } from '../channel'; import { useConnection } from '../connection'; import { fetchV2SessionGroups, fetchV2SessionsPage, type V2ActivityStatus, type V2Session, type V2SessionGroup, type V2SessionSort, } from '../sessions/api'; import { SESSION_VIEWS, sessionViewById, type SessionView } from '../sessions/views'; import { Badge, ErrorLine, relTime } from '../ui'; const STORAGE_KEY = 'kimi-inspect.session-table'; const DEFAULT_WIDTH = 320; const MIN_WIDTH = 240; const MAX_WIDTH = 640; const GROUP_PAGE_SIZE = 50; // --------------------------------------------------------------------------- // Persisted panel prefs // --------------------------------------------------------------------------- interface PanelPrefs { readonly view?: string; /** Collapsed workspace ids; absent = everything expanded. */ readonly collapsed?: readonly string[]; readonly width?: number; } function readPrefs(): PanelPrefs { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw !== null) return JSON.parse(raw) as PanelPrefs; } catch { // corrupt storage — fall through to defaults } return {}; } function writePrefs(prefs: PanelPrefs): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } // --------------------------------------------------------------------------- // Live status override (mirrors the server's mapActivityStatus) // --------------------------------------------------------------------------- function liveStatus(facts: SessionWorkFacts | undefined): V2ActivityStatus | undefined { if (facts === undefined) return undefined; if (facts.pendingInteraction === 'approval') return 'approval'; if (facts.pendingInteraction === 'question') return 'question'; if (facts.busy || facts.mainTurnActive) return 'running'; if (facts.lastTurnReason === 'failed') return 'failed'; return 'idle'; } const STATUS_TONES: Record = { running: 'green', approval: 'amber', question: 'sky', failed: 'red', idle: 'neutral', }; const SORTS: readonly { readonly id: V2SessionSort; readonly label: string }[] = [ { id: 'meta.updated_at_desc', label: 'Updated ↓' }, { id: 'meta.updated_at_asc', label: 'Updated ↑' }, { id: 'meta.created_at_desc', label: 'Created ↓' }, ]; /** * Default model for a fresh session: the configured global `defaultModel` * first (the same fallback the profile bind uses), then the first connected * provider's `default_model`. `undefined` means the server has nothing to * offer — the session stays model-less and the chat surfaces * `model.not_configured` as before. */ async function resolveDefaultModel(klient: InspectClient): Promise { const configured: unknown = await klient.core(IConfigService).get('defaultModel'); if (typeof configured === 'string' && configured !== '') return configured; const providers = await klient.core(IModelCatalog).listProviders(); const withDefault = providers.filter((p) => p.default_model !== undefined); return (withDefault.find((p) => p.status === 'connected') ?? withDefault[0])?.default_model; } // --------------------------------------------------------------------------- // Sidebar // --------------------------------------------------------------------------- export function Sidebar({ activeSessionId, onSelectSession, }: { activeSessionId: string | null; onSelectSession: (sessionId: string) => void; }) { const { klient, baseUrl, config } = useConnection(); const queryClient = useQueryClient(); const activities = useSessionActivities(); const [prefs, setPrefs] = useState(readPrefs); const view = sessionViewById(prefs.view); const [sort, setSort] = useState('meta.updated_at_desc'); const width = prefs.width ?? DEFAULT_WIDTH; const collapsed = useMemo(() => new Set(prefs.collapsed ?? []), [prefs.collapsed]); const updatePrefs = (patch: PanelPrefs) => { setPrefs((prev) => { const next: PanelPrefs = { ...prev, ...patch }; writePrefs(next); return next; }); }; const toggleCollapsed = (workspaceId: string) => { const next = new Set(collapsed); if (next.has(workspaceId)) next.delete(workspaceId); else next.add(workspaceId); updatePrefs({ collapsed: [...next] }); }; const token = config.token.trim(); const authToken = token === '' ? undefined : token; const workspaces = useQuery({ queryKey: ['workspaces'], queryFn: () => klient.core(IWorkspaceService).list(), refetchInterval: 15_000, }); const workspaceNames = useMemo( () => new Map((workspaces.data ?? []).map((ws) => [ws.id, ws.name] as const)), [workspaces.data], ); const groups = useInfiniteQuery({ queryKey: ['v2-sessions', 'tree', view.id, sort], queryFn: ({ pageParam }) => fetchV2SessionGroups({ baseUrl, token: authToken, statuses: view.statuses, archived: view.archived, includeGit: view.includeGit, sort, pageSize: 50, groupPageSize: GROUP_PAGE_SIZE, pageToken: pageParam, }), initialPageParam: undefined as string | undefined, getNextPageParam: (last) => last.nextPageToken, refetchInterval: 15_000, }); const groupList = useMemo( () => groups.data?.pages.flatMap((page) => page.groups) ?? [], [groups.data], ); const createSession = async (ws: Workspace | null) => { // With a workspace, the server derives workDir from workspace.root, so no cwd is needed. let body: string; if (ws !== null) { body = JSON.stringify({ workspace_id: ws.id }); } else { const cwd = window.prompt('Working directory for the new session:', ''); if (cwd === null || cwd.trim() === '') return; body = JSON.stringify({ metadata: { cwd: cwd.trim() } }); } const headers: Record = { 'content-type': 'application/json' }; if (token !== '') headers['authorization'] = `Bearer ${token}`; const res = await fetch(`${baseUrl}/api/v1/sessions`, { method: 'POST', headers, body, }); const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } }; if (envelope.code !== 0) { window.alert(`create session failed: ${envelope.msg}`); return; } const sessionId = envelope.data.id; // The REST create route ignores agent_config, so bind the default model // over the channel — the same resume + setModel path the Model Catalog's // "+ Session" button uses. Best-effort: a failure leaves the session // model-less instead of blocking the creation flow. try { const model = await resolveDefaultModel(klient); if (model !== undefined) { await klient.core(ISessionManager).resume(sessionId); await klient.session(sessionId).agent('main').service(IAgentProfileService).setModel(model); } } catch (error) { console.warn('failed to set the default model on the new session', error); } await queryClient.invalidateQueries({ queryKey: ['v2-sessions'] }); onSelectSession(sessionId); }; const cycleSort = () => { const index = SORTS.findIndex((s) => s.id === sort); setSort(SORTS[(index + 1) % SORTS.length]!.id); }; const startResize = (e: React.MouseEvent) => { e.preventDefault(); const startX = e.clientX; const startWidth = width; const onMove = (ev: MouseEvent) => { const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + ev.clientX - startX)); setPrefs((prev) => ({ ...prev, width: next })); }; const onUp = (ev: MouseEvent) => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + ev.clientX - startX)); updatePrefs({ width: next }); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }; return (
{/* View tabs */}
{SESSION_VIEWS.map((v) => ( ))}
{/* Toolbar: new session + sort */}
{/* Tree body */}
{groups.isError ? : null} {groupList.map((group) => ( toggleCollapsed(group.workspace.id)} workspaceNames={workspaceNames} activeSessionId={activeSessionId} activityOf={(id) => activities.get(id)} onSelect={onSelectSession} baseUrl={baseUrl} token={authToken} /> ))} {groups.isLoading ? (
loading…
) : null} {!groups.isLoading && groupList.length === 0 && !groups.isError ? (
no sessions
) : null} {groups.hasNextPage ? ( ) : null}
{/* Resize handle */}
); } // --------------------------------------------------------------------------- // Tree nodes // --------------------------------------------------------------------------- function WorkspaceNode({ group, view, sort, collapsed, onToggleCollapsed, workspaceNames, activeSessionId, activityOf, onSelect, baseUrl, token, }: { group: V2SessionGroup; view: SessionView; sort: V2SessionSort; collapsed: boolean; onToggleCollapsed: () => void; workspaceNames: ReadonlyMap; activeSessionId: string | null; activityOf: (sessionId: string) => SessionWorkFacts | undefined; onSelect: (sessionId: string) => void; baseUrl: string; token?: string | undefined; }) { const [showAll, setShowAll] = useState(false); const hasMore = group.total > group.sessions.length; return (
{collapsed ? '▸' : '▾'} {workspaceNames.get(group.workspace.id) ?? group.workspace.cwd ?? group.workspace.id} {group.total}
{collapsed ? null : ( <> {showAll ? null : ( <> {group.sessions.map((s) => ( onSelect(s.id)} /> ))} {hasMore ? ( ) : null} )} {showAll ? ( ) : null} )}
); } /** * The flat per-workspace listing behind "Show all" — pages the ungrouped * projection filtered to this workspace, so sessions beyond the grouped * slice stay reachable. */ function FullGroupList({ workspaceId, view, sort, baseUrl, token, activeSessionId, activityOf, onSelect, }: { workspaceId: string; view: SessionView; sort: V2SessionSort; baseUrl: string; token?: string | undefined; activeSessionId: string | null; activityOf: (sessionId: string) => SessionWorkFacts | undefined; onSelect: (sessionId: string) => void; }) { const sessions = useInfiniteQuery({ queryKey: ['v2-sessions', 'tree-full', workspaceId, view.id, sort], queryFn: ({ pageParam }) => fetchV2SessionsPage({ baseUrl, token, workspaceIds: [workspaceId], statuses: view.statuses, archived: view.archived, includeGit: view.includeGit, sort, pageSize: 50, pageToken: pageParam, }), initialPageParam: undefined as string | undefined, getNextPageParam: (last) => last.nextPageToken, }); const seen = new Set(); const items = (sessions.data?.pages.flatMap((page) => page.items) ?? []).filter((s) => { if (seen.has(s.id)) return false; seen.add(s.id); return true; }); return (
{items.map((s) => ( onSelect(s.id)} /> ))} {sessions.isLoading ? (
loading…
) : null} {sessions.hasNextPage ? ( ) : null}
); } function SessionNode({ s, active, activity, onClick, }: { s: V2Session; active: boolean; activity?: SessionWorkFacts | undefined; onClick: () => void; }) { const status = liveStatus(activity) ?? s.activity.status; return (
{status === 'idle' ? null : {status}} {s.meta.title ?? s.meta.lastPrompt ?? s.id} {s.meta.archived ? archived : null} {relTime(s.meta.updatedAt)}
{s.id.slice(0, 12)} {s.git !== undefined && s.git.branch !== null ? ( {s.git.branch} ) : null} {s.git !== undefined && s.git.pullRequest !== null ? ( e.stopPropagation()} > #{s.git.pullRequest.number} ) : null}
); } // --------------------------------------------------------------------------- // Toolbar dropdowns // --------------------------------------------------------------------------- function useDropdown(): { open: boolean; toggle: () => void; close: () => void } { const [open, setOpen] = useState(false); return { open, toggle: () => setOpen((v) => !v), close: () => setOpen(false), }; } function NewSessionMenu({ workspaces, onCreate, }: { workspaces: readonly Workspace[]; onCreate: (ws: Workspace | null) => Promise; }) { const { open, toggle, close } = useDropdown(); return (
{open ? ( <>
{workspaces.map((ws) => ( ))}
) : null}
); }