feat(ui): surface retrying session status

Preserve retry metadata from session.status events so the session list and header can show a live retry countdown with context. Notify users when a session enters retry and reuse the existing error styling so retrying feels actionable without losing the current badge layout.
This commit is contained in:
Shantur
2026-03-31 18:38:54 +01:00
parent fe932c8307
commit 3f6cdd36f3
16 changed files with 194 additions and 20 deletions

View File

@@ -41,7 +41,7 @@ import SessionSidebar from "./shell/SessionSidebar"
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getSessionStatus } from "../../stores/session-status"
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
import { Maximize2, ShieldAlert } from "lucide-solid"
import type { LayoutMode } from "./shell/types"
@@ -97,6 +97,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [now, setNow] = createSignal(Date.now())
// Worktree selector manages its own dialogs.
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
@@ -230,6 +231,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
})
createEffect(() => {
if (typeof window === "undefined") return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
onCleanup(() => window.clearInterval(timer))
})
const connectionStatus = () => sseManager.getStatus(props.instance.id)
const connectionStatusClass = () => {
const status = connectionStatus()
@@ -272,17 +279,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
const status = getSessionStatus(props.instance.id, activeSessionId)
const text =
status === "working"
const retry = getSessionRetry(props.instance.id, activeSessionId)
const text = retry
? (() => {
const seconds = getRetrySeconds(retry.next, now())
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
})()
: status === "working"
? t("sessionList.status.working")
: status === "compacting"
? t("sessionList.status.compacting")
: t("sessionList.status.idle")
return {
className: `session-${status}`,
className: `session-${retry ? "retrying" : status}`,
text,
showAlertIcon: false,
title: retry
? t("sessionList.status.retryTooltip", {
message: retry.message,
attempt: String(retry.attempt),
})
: undefined,
}
})
@@ -290,7 +308,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const pill = activeSessionStatusPill()
if (!pill) return null
return (
<span class={`status-indicator session-status session-status-list ${pill.className}`}>
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{pill.text}
</span>

View File

@@ -1,7 +1,7 @@
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status"
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
import KeyboardHint from "./keyboard-hint"
import SessionRenameDialog from "./session-rename-dialog"
@@ -55,6 +55,13 @@ const SessionList: Component<SessionListProps> = (props) => {
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
const [now, setNow] = createSignal(Date.now())
createEffect(() => {
if (typeof window === "undefined") return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
onCleanup(() => window.clearInterval(timer))
})
const normalizeSessionLabel = (sessionId: string) => {
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
@@ -400,7 +407,13 @@ const SessionList: Component<SessionListProps> = (props) => {
const isActive = () => props.activeSessionId === rowProps.sessionId
const title = () => session()?.title || t("sessionList.session.untitled")
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId)
const statusLabel = () => {
const retryState = retry()
if (retryState) {
const seconds = getRetrySeconds(retryState.next, now())
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
}
switch (formatSessionStatus(status())) {
case "working":
return t("sessionList.status.working")
@@ -413,13 +426,21 @@ const SessionList: Component<SessionListProps> = (props) => {
const needsPermission = () => Boolean(session()?.pendingPermission)
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
const needsInput = () => needsPermission() || needsQuestion()
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`)
const statusText = () =>
needsPermission()
? t("sessionList.status.needsPermission")
: needsQuestion()
? t("sessionList.status.needsInput")
: statusLabel()
const statusTooltip = () => {
const retryState = retry()
if (!retryState) return undefined
return t("sessionList.status.retryTooltip", {
message: retryState.message,
attempt: String(retryState.attempt),
})
}
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
@@ -499,7 +520,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span>
</Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()}
</span>

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Working",
"sessionList.status.compacting": "Compacting",
"sessionList.status.idle": "Idle",
"sessionList.status.retrying": "Retrying",
"sessionList.status.retryingIn": "Retrying in {seconds}s",
"sessionList.status.retryTooltip": "{message} (Attempt {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Attempt {attempt})",
"sessionList.status.needsPermission": "Needs Permission",
"sessionList.status.needsInput": "Needs Input",
"sessionList.expand.collapseAriaLabel": "Collapse session",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Trabajando",
"sessionList.status.compacting": "Compactando",
"sessionList.status.idle": "Inactiva",
"sessionList.status.retrying": "Reintentando",
"sessionList.status.retryingIn": "Reintentando en {seconds}s",
"sessionList.status.retryTooltip": "{message} (Intento {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Intento {attempt})",
"sessionList.status.needsPermission": "Requiere permiso",
"sessionList.status.needsInput": "Requiere entrada",
"sessionList.expand.collapseAriaLabel": "Colapsar sesión",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "En cours",
"sessionList.status.compacting": "Compactage",
"sessionList.status.idle": "Inactif",
"sessionList.status.retrying": "Nouvelle tentative",
"sessionList.status.retryingIn": "Nouvelle tentative dans {seconds}s",
"sessionList.status.retryTooltip": "{message} (Tentative {attempt})",
"sessionList.status.retryToast": "{countdown} : {message} (Tentative {attempt})",
"sessionList.status.needsPermission": "Autorisation requise",
"sessionList.status.needsInput": "Entrée requise",
"sessionList.expand.collapseAriaLabel": "Réduire la session",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "עובד",
"sessionList.status.compacting": "מסכם",
"sessionList.status.idle": "מוכן",
"sessionList.status.retrying": "מנסה שוב",
"sessionList.status.retryingIn": "מנסה שוב בעוד {seconds}ש׳",
"sessionList.status.retryTooltip": "{message} (ניסיון {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (ניסיון {attempt})",
"sessionList.status.needsPermission": "נדרש אישור",
"sessionList.status.needsInput": "נדרש קלט",
"sessionList.expand.collapseAriaLabel": "כווץ סשן",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "作業中",
"sessionList.status.compacting": "圧縮中",
"sessionList.status.idle": "待機中",
"sessionList.status.retrying": "再試行中",
"sessionList.status.retryingIn": "{seconds}秒後に再試行",
"sessionList.status.retryTooltip": "{message}{attempt}回目)",
"sessionList.status.retryToast": "{countdown}: {message}{attempt}回目)",
"sessionList.status.needsPermission": "許可待ち",
"sessionList.status.needsInput": "入力待ち",
"sessionList.expand.collapseAriaLabel": "セッションを折りたたむ",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Работает",
"sessionList.status.compacting": "Компактация",
"sessionList.status.idle": "Простой",
"sessionList.status.retrying": "Повтор",
"sessionList.status.retryingIn": "Повтор через {seconds}с",
"sessionList.status.retryTooltip": "{message} (Попытка {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Попытка {attempt})",
"sessionList.status.needsPermission": "Требуется разрешение",
"sessionList.status.needsInput": "Требуется ввод",
"sessionList.expand.collapseAriaLabel": "Свернуть сессию",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "工作中",
"sessionList.status.compacting": "压缩中",
"sessionList.status.idle": "空闲",
"sessionList.status.retrying": "重试中",
"sessionList.status.retryingIn": "{seconds} 秒后重试",
"sessionList.status.retryTooltip": "{message}(第 {attempt} 次尝试)",
"sessionList.status.retryToast": "{countdown}: {message}(第 {attempt} 次尝试)",
"sessionList.status.needsPermission": "需要权限",
"sessionList.status.needsInput": "需要输入",
"sessionList.expand.collapseAriaLabel": "折叠会话",

View File

@@ -1,4 +1,4 @@
import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import { mapSdkSessionRetry, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import type { Message } from "../types/message"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
@@ -149,12 +149,15 @@ async function fetchSessions(instanceId: string): Promise<void> {
const existingStatus = existingSession?.status
let status: SessionStatus
let retry = existingSession?.retry ?? null
if (existingStatus === "compacting") {
status = "compacting"
retry = null
} else {
const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle"
retry = hasType ? mapSdkSessionRetry(rawStatus) : retry
}
sessionMap.set(apiSession.id, {
@@ -165,6 +168,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" },
status,
retry,
version: apiSession.version,
time: {
...apiSession.time,

View File

@@ -28,7 +28,7 @@ import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "
import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question"
import type { QuestionRequest } from "../types/question"
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { showToastNotification, type ToastHandle, ToastVariant } from "../lib/notifications"
import { sendOsNotification } from "../lib/os-notifications"
import { preferences } from "./preferences"
import {
@@ -39,7 +39,14 @@ import {
removeQuestionFromQueue,
} from "./instances"
import { showAlertDialog } from "./alerts"
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import {
createClientSession,
mapSdkSessionRetry,
mapSdkSessionStatus,
type Session,
type SessionRetryState,
type SessionStatus,
} from "../types/session"
import { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
@@ -67,6 +74,15 @@ import { handleConversationAssistantPartUpdated } from "./conversation-speech"
const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>()
let activeRetryToast: ToastHandle | null = null
function isSameRetryState(left: SessionRetryState | null | undefined, right: SessionRetryState | null | undefined): boolean {
const a = left ?? null
const b = right ?? null
if (a === b) return true
if (!a || !b) return false
return a.attempt === b.attempt && a.message === b.message && a.next === b.next
}
function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
if (typeof document === "undefined") return false
@@ -131,18 +147,20 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus, retry?: SessionRetryState | null) {
let parentToExpand: string | null = null
withSession(instanceId, sessionId, (session) => {
const current = session.status ?? "idle"
if (current === status) return false
const nextRetry = retry ?? null
if (current === status && isSameRetryState(session.retry, nextRetry)) return false
if (current === "compacting" && status !== "compacting") {
return false
}
session.status = status
session.retry = status === "working" ? nextRetry : null
// Auto-expand the parent thread when a child session starts working.
// Users can still collapse it; we only expand on the transition.
@@ -172,6 +190,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
)
let fetchedStatus: SessionStatus = "idle"
let fetchedRetry: SessionRetryState | null = null
try {
let statuses: Record<string, any> = {}
try {
@@ -187,11 +206,13 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
fetchedRetry = hasType ? mapSdkSessionRetry(rawStatus) : null
} catch (error) {
log.error("Failed to fetch session status", error)
}
const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus)
fetched.retry = fetchedRetry
let updatedInstanceSessions: Map<string, Session> | undefined
let shouldExpandParent: string | null = null
@@ -205,6 +226,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
agent: existing?.agent ?? fetched.agent,
model: existing?.model ?? fetched.model,
status: existing?.status === "compacting" ? "compacting" : fetched.status,
retry: existing?.status === "compacting" ? null : fetched.retry,
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
pendingQuestion: existing?.pendingQuestion ?? false,
}
@@ -231,14 +253,20 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
}
}
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus, directory?: string) {
function ensureSessionStatus(
instanceId: string,
sessionId: string,
status: SessionStatus,
directory?: string,
retry?: SessionRetryState | null,
) {
const instanceSessions = sessions().get(instanceId)
const existing = instanceSessions?.get(sessionId)
if (existing) {
if ((existing.status ?? "idle") === status) {
if ((existing.status ?? "idle") === status && isSameRetryState(existing.retry, retry)) {
return
}
applySessionStatus(instanceId, sessionId, status)
applySessionStatus(instanceId, sessionId, status, retry)
return
}
@@ -250,7 +278,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
const pending = (async () => {
const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
if (!fetched) return
applySessionStatus(instanceId, sessionId, status)
applySessionStatus(instanceId, sessionId, status, retry)
})()
pendingSessionFetches.set(key, pending)
@@ -428,6 +456,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
modelId: "",
},
status: "idle",
retry: null,
version: info.version || "0",
time: info.time
? { ...info.time }
@@ -461,6 +490,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
...existingSession,
title: info.title || existingSession.title,
status: existingSession.status ?? "idle",
retry: existingSession.retry ?? null,
time: mergedTime,
revert: info.revert
? {
@@ -532,8 +562,29 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
const sessionId = event.properties?.sessionID
if (!sessionId) return
const status = mapSdkSessionStatus(event.properties.status)
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory)
const rawStatus = event.properties.status
const status = mapSdkSessionStatus(rawStatus)
const retry = mapSdkSessionRetry(rawStatus)
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory, retry)
if (retry) {
const remainingSeconds = Math.max(0, Math.round((retry.next - Date.now()) / 1000))
const countdown =
remainingSeconds > 0
? tGlobal("sessionList.status.retryingIn", { seconds: String(remainingSeconds) })
: tGlobal("sessionList.status.retrying")
const label = getSessionTitle(instanceId, sessionId)
activeRetryToast?.dismiss()
activeRetryToast = showToastNotification({
title: label || getInstanceDisplayName(instanceId),
message: tGlobal("sessionList.status.retryToast", {
countdown,
message: retry.message,
attempt: String(retry.attempt),
}),
variant: "error",
duration: 7000,
})
}
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
}
@@ -547,6 +598,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
if (existing) {
withSession(instanceId, sessionID, (session) => {
session.status = "working"
session.retry = null
})
} else {
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)

View File

@@ -353,6 +353,9 @@ function setSessionStatus(instanceId: string, sessionId: string, status: Session
if (session.status === status) return false
const previous = session.status
session.status = status
if (status !== "working") {
session.retry = null
}
// If a child session starts working, auto-expand its parent thread once.
// Users can still collapse it afterwards; we only expand on the transition.

View File

@@ -1,4 +1,4 @@
import type { Session, SessionStatus } from "../types/session"
import type { Session, SessionRetryState, SessionStatus } from "../types/session"
import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
function getSession(instanceId: string, sessionId: string): Session | null {
@@ -14,6 +14,15 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
return session.status ?? "idle"
}
export function getSessionRetry(instanceId: string, sessionId: string): SessionRetryState | null {
const session = getSession(instanceId, sessionId)
return session?.retry ?? null
}
export function getRetrySeconds(next: number, now = Date.now()): number {
return Math.max(0, Math.round((next - now) / 1000))
}
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus {

View File

@@ -184,6 +184,7 @@
}
.status-indicator.session-status.session-working,
.status-indicator.session-status.session-retrying,
.status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium);
@@ -194,6 +195,11 @@
--session-status-dot: var(--session-status-working-fg);
}
.status-indicator.session-status.session-retrying {
color: var(--status-error);
--session-status-dot: var(--status-error);
}
.status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg);
@@ -222,6 +228,10 @@
background-color: var(--session-status-working-bg);
}
.status-indicator.session-status.session-retrying.session-status-list {
background-color: var(--status-error-bg);
}
.status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg);
}

View File

@@ -394,6 +394,7 @@ session-sidebar-controls .selector-trigger-primary {
}
.status-indicator.session-status.session-working,
.status-indicator.session-status.session-retrying,
.status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium);
@@ -404,6 +405,11 @@ session-sidebar-controls .selector-trigger-primary {
--session-status-dot: var(--session-status-working-fg);
}
.status-indicator.session-status.session-retrying {
color: var(--status-error);
--session-status-dot: var(--status-error);
}
.status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg);
@@ -432,6 +438,10 @@ session-sidebar-controls .selector-trigger-primary {
background-color: var(--session-status-working-bg);
}
.status-indicator.session-status.session-retrying.session-status-list {
background-color: var(--status-error-bg);
}
.status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg);
}

View File

@@ -17,6 +17,12 @@ export type {
export type SessionStatus = "idle" | "working" | "compacting"
export interface SessionRetryState {
attempt: number
message: string
next: number
}
export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus {
if (!status || status.type === "idle") {
return "idle"
@@ -26,6 +32,18 @@ export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined)
return "working"
}
export function mapSdkSessionRetry(status: SDKSessionStatus | null | undefined): SessionRetryState | null {
if (!status || status.type !== "retry") {
return null
}
return {
attempt: typeof status.attempt === "number" ? status.attempt : 1,
message: typeof status.message === "string" ? status.message : "",
next: typeof status.next === "number" ? status.next : Date.now(),
}
}
// Our client-specific Session interface extending SDK Session
export interface Session
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
@@ -40,6 +58,7 @@ export interface Session
pendingPermission?: boolean // Indicates if session is waiting on user permission
pendingQuestion?: boolean // Indicates if session is waiting on user input
status: SessionStatus // Single source of truth for session status
retry?: SessionRetryState | null // Retry metadata for transient backoff states
diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff)
}