import { batch, createSignal } from "solid-js" import type { Session, SessionStatus, Agent, Provider } from "../types/session" import { deleteSession, loadMessages } from "./session-api" import { showToastNotification } from "../lib/notifications" import { messageStoreBus } from "./message-v2/bus" import { instances } from "./instances" import { showConfirmDialog } from "./alerts" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" import { tGlobal } from "../lib/i18n" const log = getLogger("session") export interface SessionInfo { cost: number contextWindow: number isSubscriptionModel: boolean inputTokens: number outputTokens: number reasoningTokens: number actualUsageTokens: number modelOutputLimit: number contextAvailableTokens: number | null } export type SessionThread = { parent: Session children: Session[] latestUpdated: number } const [sessions, setSessions] = createSignal>>(new Map()) const [activeSessionId, setActiveSessionId] = createSignal>(new Map()) const [activeParentSessionId, setActiveParentSessionId] = createSignal>(new Map()) const [agents, setAgents] = createSignal>(new Map()) const [providers, setProviders] = createSignal>(new Map()) const [sessionDraftPrompts, setSessionDraftPrompts] = createSignal>(new Map()) const [loading, setLoading] = createSignal({ fetchingSessions: new Map(), creatingSession: new Map(), deletingSession: new Map>(), loadingMessages: new Map>(), }) const [messagesLoaded, setMessagesLoaded] = createSignal>>(new Map()) const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) const [expandedSessionParents, setExpandedSessionParents] = createSignal>>(new Map()) export type InstanceSessionIndicatorStatus = "permission" | SessionStatus type InstanceIndicatorCounts = { permission: number working: number compacting: number } const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal>(new Map()) function getIndicatorBucket(session: Pick): InstanceSessionIndicatorStatus | "idle" { if (session.pendingPermission || session.pendingQuestion) { return "permission" } const status = session.status ?? "idle" return status } function adjustIndicatorCounts( instanceId: string, previous: InstanceSessionIndicatorStatus | "idle", next: InstanceSessionIndicatorStatus | "idle", ): void { if (previous === next) return const decKey = previous === "idle" ? null : previous const incKey = next === "idle" ? null : next setInstanceIndicatorCounts((prev) => { const current = prev.get(instanceId) ?? { permission: 0, working: 0, compacting: 0 } const updated: InstanceIndicatorCounts = { ...current } if (decKey) { updated[decKey] = Math.max(0, updated[decKey] - 1) } if (incKey) { updated[incKey] = updated[incKey] + 1 } const hasAny = updated.permission > 0 || updated.working > 0 || updated.compacting > 0 if (!hasAny) { if (!prev.has(instanceId)) return prev const nextMap = new Map(prev) nextMap.delete(instanceId) return nextMap } const same = current.permission === updated.permission && current.working === updated.working && current.compacting === updated.compacting if (same && prev.has(instanceId)) { return prev } const nextMap = new Map(prev) nextMap.set(instanceId, updated) return nextMap }) } function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map | undefined): void { if (!instanceSessions || instanceSessions.size === 0) { setInstanceIndicatorCounts((prev) => { if (!prev.has(instanceId)) return prev const next = new Map(prev) next.delete(instanceId) return next }) return } let permission = 0 let working = 0 let compacting = 0 for (const session of instanceSessions.values()) { if (session.pendingPermission || session.pendingQuestion) { permission += 1 continue } const status = session.status ?? "idle" if (status === "compacting") { compacting += 1 } else if (status === "working") { working += 1 } } if (permission === 0 && working === 0 && compacting === 0) { setInstanceIndicatorCounts((prev) => { if (!prev.has(instanceId)) return prev const next = new Map(prev) next.delete(instanceId) return next }) return } setInstanceIndicatorCounts((prev) => { const current = prev.get(instanceId) if (current && current.permission === permission && current.working === working && current.compacting === compacting) { return prev } const next = new Map(prev) next.set(instanceId, { permission, working, compacting }) return next }) } export function getInstanceSessionIndicatorStatusCached(instanceId: string): InstanceSessionIndicatorStatus { const counts = instanceIndicatorCounts().get(instanceId) if (!counts) return "idle" if (counts.permission > 0) return "permission" if (counts.compacting > 0) return "compacting" if (counts.working > 0) return "working" return "idle" } export function syncInstanceSessionIndicator(instanceId: string, instanceSessions?: Map): void { recomputeIndicatorCounts(instanceId, instanceSessions ?? sessions().get(instanceId)) } function clearLoadedFlag(instanceId: string, sessionId: string) { if (!instanceId || !sessionId) return setMessagesLoaded((prev) => { const existing = prev.get(instanceId) if (!existing || !existing.has(sessionId)) { return prev } const next = new Map(prev) const updated = new Set(existing) updated.delete(sessionId) if (updated.size === 0) { next.delete(instanceId) } else { next.set(instanceId, updated) } return next }) } messageStoreBus.onSessionCleared((instanceId, sessionId) => { clearLoadedFlag(instanceId, sessionId) }) function getDraftKey(instanceId: string, sessionId: string): string { return `${instanceId}:${sessionId}` } function getSessionDraftPrompt(instanceId: string, sessionId: string): string { if (!instanceId || !sessionId) return "" const key = getDraftKey(instanceId, sessionId) return sessionDraftPrompts().get(key) ?? "" } function setSessionDraftPrompt(instanceId: string, sessionId: string, value: string) { const key = getDraftKey(instanceId, sessionId) setSessionDraftPrompts((prev) => { const next = new Map(prev) if (!value) { next.delete(key) } else { next.set(key, value) } return next }) } function clearSessionDraftPrompt(instanceId: string, sessionId: string) { const key = getDraftKey(instanceId, sessionId) setSessionDraftPrompts((prev) => { if (!prev.has(key)) return prev const next = new Map(prev) next.delete(key) return next }) } function clearInstanceDraftPrompts(instanceId: string) { if (!instanceId) return setSessionDraftPrompts((prev) => { let changed = false const next = new Map(prev) const prefix = `${instanceId}:` for (const key of Array.from(next.keys())) { if (key.startsWith(prefix)) { next.delete(key) changed = true } } return changed ? next : prev }) } function pruneDraftPrompts(instanceId: string, validSessionIds: Set) { setSessionDraftPrompts((prev) => { let changed = false const next = new Map(prev) const prefix = `${instanceId}:` for (const key of Array.from(next.keys())) { if (key.startsWith(prefix)) { const sessionId = key.slice(prefix.length) if (!validSessionIds.has(sessionId)) { next.delete(key) changed = true } } } return changed ? next : prev }) } function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void | boolean) { let previousBucket: InstanceSessionIndicatorStatus | "idle" | null = null let nextBucket: InstanceSessionIndicatorStatus | "idle" | null = null let didUpdate = false setSessions((prev) => { const instanceSessions = prev.get(instanceId) if (!instanceSessions) return prev const current = instanceSessions.get(sessionId) if (!current) return prev previousBucket = getIndicatorBucket(current) const updatedSession: Session = { ...current } const result = updater(updatedSession) if (result === false) { return prev } nextBucket = getIndicatorBucket(updatedSession) instanceSessions.set(sessionId, updatedSession) didUpdate = true const next = new Map(prev) next.set(instanceId, instanceSessions) return next }) if (didUpdate && previousBucket && nextBucket) { adjustIndicatorCounts(instanceId, previousBucket, nextBucket) } } function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void { withSession(instanceId, sessionId, (session) => { if (session.pendingPermission === pending) return false session.pendingPermission = pending }) } function setSessionPendingQuestion(instanceId: string, sessionId: string, pending: boolean): void { withSession(instanceId, sessionId, (session) => { if (session.pendingQuestion === pending) return false session.pendingQuestion = pending }) } function setActiveSession(instanceId: string, sessionId: string): void { setActiveSessionId((prev) => { const next = new Map(prev) next.set(instanceId, sessionId) return next }) } function setActiveParentSession(instanceId: string, parentSessionId: string): void { setActiveParentSessionId((prev) => { const next = new Map(prev) next.set(instanceId, parentSessionId) return next }) setActiveSession(instanceId, parentSessionId) } function clearActiveParentSession(instanceId: string): void { setActiveParentSessionId((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) setActiveSessionId((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) } function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void { let parentToExpand: string | null = null withSession(instanceId, sessionId, (session) => { if (session.status === status) return false const previous = session.status session.status = status // If a child session starts working, auto-expand its parent thread once. // Users can still collapse it afterwards; we only expand on the transition. if (session.parentId && status === "working" && previous !== "working") { parentToExpand = session.parentId } }) if (parentToExpand) { ensureSessionParentExpanded(instanceId, parentToExpand) } } function getActiveParentSession(instanceId: string): Session | null { const parentId = activeParentSessionId().get(instanceId) if (!parentId) return null const instanceSessions = sessions().get(instanceId) return instanceSessions?.get(parentId) || null } function getActiveSession(instanceId: string): Session | null { const sessionId = activeSessionId().get(instanceId) if (!sessionId) return null const instanceSessions = sessions().get(instanceId) return instanceSessions?.get(sessionId) || null } function getSessions(instanceId: string): Session[] { const instanceSessions = sessions().get(instanceId) return instanceSessions ? Array.from(instanceSessions.values()) : [] } function getParentSessions(instanceId: string): Session[] { const allSessions = getSessions(instanceId) return allSessions.filter((s) => s.parentId === null) } function getChildSessions(instanceId: string, parentId: string): Session[] { const allSessions = getSessions(instanceId) return allSessions.filter((s) => s.parentId === parentId) } function getSessionFamily(instanceId: string, parentId: string): Session[] { const parent = sessions().get(instanceId)?.get(parentId) if (!parent) return [] const children = getChildSessions(instanceId, parentId) return [parent, ...children] } type SessionThreadCacheEntry = { signature: string thread: SessionThread } type SessionThreadCache = { byParentId: Map } const sessionThreadCache = new Map() function getOrCreateSessionThreadCache(instanceId: string): SessionThreadCache { let cache = sessionThreadCache.get(instanceId) if (!cache) { cache = { byParentId: new Map() } sessionThreadCache.set(instanceId, cache) } return cache } function getSessionThreads(instanceId: string): SessionThread[] { const instanceSessions = sessions().get(instanceId) if (!instanceSessions || instanceSessions.size === 0) { sessionThreadCache.delete(instanceId) return [] } const cache = getOrCreateSessionThreadCache(instanceId) const seenParents = new Set() const parents: Session[] = [] const childrenByParent = new Map() for (const session of instanceSessions.values()) { if (session.parentId === null) { parents.push(session) continue } const parentId = session.parentId if (!parentId) continue const children = childrenByParent.get(parentId) if (children) { children.push(session) } else { childrenByParent.set(parentId, [session]) } } const threads: SessionThread[] = [] for (const parent of parents) { seenParents.add(parent.id) const children = childrenByParent.get(parent.id) ?? [] if (children.length > 1) { children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0)) } const parentUpdated = parent.time.updated ?? 0 const latestChild = children[0]?.time.updated ?? 0 const latestUpdated = Math.max(parentUpdated, latestChild) const childIds = children.map((child) => child.id).join(",") const signature = `${parentUpdated}:${latestChild}:${childIds}` const cached = cache.byParentId.get(parent.id) if (cached && cached.signature === signature) { threads.push(cached.thread) } else { const thread: SessionThread = { parent, children, latestUpdated } cache.byParentId.set(parent.id, { signature, thread }) threads.push(thread) } } for (const parentId of Array.from(cache.byParentId.keys())) { if (!seenParents.has(parentId)) { cache.byParentId.delete(parentId) } } threads.sort((a, b) => { if (b.latestUpdated !== a.latestUpdated) return b.latestUpdated - a.latestUpdated const bParentUpdated = b.parent.time.updated ?? 0 const aParentUpdated = a.parent.time.updated ?? 0 if (bParentUpdated !== aParentUpdated) return bParentUpdated - aParentUpdated return b.parent.id.localeCompare(a.parent.id) }) return threads } function isSessionParentExpanded(instanceId: string, parentSessionId: string): boolean { return Boolean(expandedSessionParents().get(instanceId)?.has(parentSessionId)) } function setSessionParentExpanded(instanceId: string, parentSessionId: string, expanded: boolean): void { setExpandedSessionParents((prev) => { const next = new Map(prev) const currentSet = next.get(instanceId) ?? new Set() const updated = new Set(currentSet) if (expanded) { updated.add(parentSessionId) } else { updated.delete(parentSessionId) } if (updated.size === 0) { next.delete(instanceId) } else { next.set(instanceId, updated) } return next }) } function toggleSessionParentExpanded(instanceId: string, parentSessionId: string): void { setExpandedSessionParents((prev) => { const next = new Map(prev) const currentSet = next.get(instanceId) ?? new Set() const updated = new Set(currentSet) if (updated.has(parentSessionId)) { updated.delete(parentSessionId) } else { updated.add(parentSessionId) } next.set(instanceId, updated) return next }) } function ensureSessionParentExpanded(instanceId: string, parentSessionId: string): void { if (isSessionParentExpanded(instanceId, parentSessionId)) return setSessionParentExpanded(instanceId, parentSessionId, true) } function getVisibleSessionIds(instanceId: string): string[] { const threads = getSessionThreads(instanceId) if (threads.length === 0) return [] const expanded = expandedSessionParents().get(instanceId) const ids: string[] = [] for (const thread of threads) { ids.push(thread.parent.id) if (expanded?.has(thread.parent.id)) { for (const child of thread.children) { ids.push(child.id) } } } return ids } function setActiveSessionFromList(instanceId: string, sessionId: string): void { const session = sessions().get(instanceId)?.get(sessionId) if (!session) return if (session.parentId === null) { setActiveParentSession(instanceId, sessionId) return } const parentId = session.parentId if (!parentId) return batch(() => { setActiveParentSession(instanceId, parentId) setActiveSession(instanceId, sessionId) }) } function isSessionBusy(instanceId: string, sessionId: string): boolean { const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return false if (!instanceSessions.has(sessionId)) return false return true } function isSessionMessagesLoading(instanceId: string, sessionId: string): boolean { return Boolean(loading().loadingMessages.get(instanceId)?.has(sessionId)) } function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | undefined { return sessionInfoByInstance().get(instanceId)?.get(sessionId) } async function isBlankSession(session: Session, instanceId: string, fetchIfNeeded = false): Promise { const created = session.time?.created || 0 const updated = session.time?.updated || 0 const hasChildren = getChildSessions(instanceId, session.id).length > 0 const isFreshSession = created === updated && !hasChildren // Common short-circuit: fresh sessions without children if (!fetchIfNeeded) { return isFreshSession } // For a more thorough deep clean, we need to look at actual messages const instance = instances().get(instanceId) if (!instance?.client) { return isFreshSession } let messages: any[] = [] try { const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id) const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) messages = await requestData( client.session.messages({ sessionID: session.id }), "session.messages", ) } catch (error) { log.error(`Failed to fetch messages for session ${session.id}`, error) return isFreshSession } // Specific logic by session type if (session.parentId === null) { // Parent: blank if no messages and no children (fresh !== blank sometimes!) const hasChildren = getChildSessions(instanceId, session.id).length > 0 return messages.length === 0 && !hasChildren } else if (session.title?.includes("subagent)")) { // Subagent: "blank" (really: finished doing its job) if actually blank... // ... OR no streaming, no pending perms, no tool parts if (messages.length === 0) return true const hasStreaming = messages.some((msg) => { const info = msg.info.status || msg.status return info === "streaming" || info === "sending" }) const lastMessage = messages[messages.length - 1] const lastParts = lastMessage?.parts || [] const hasToolPart = lastParts.some((part: any) => part.type === "tool" || part.data?.type === "tool" ) return !hasStreaming && !session.pendingPermission && !hasToolPart } else { // Fork: blank if somehow has no messages or at revert point if (messages.length === 0) return true const lastMessage = messages[messages.length - 1] const lastInfo = lastMessage?.info || lastMessage return lastInfo?.id === session.revert?.messageID } } async function cleanupBlankSessions(instanceId: string, excludeSessionId?: string, fetchIfNeeded = false): Promise { const instanceSessions = sessions().get(instanceId) if (!instanceSessions) return if (fetchIfNeeded) { const confirmed = await showConfirmDialog( tGlobal("sessionState.cleanup.deepConfirm.message"), { title: tGlobal("sessionState.cleanup.deepConfirm.title"), detail: tGlobal("sessionState.cleanup.deepConfirm.detail"), confirmLabel: tGlobal("sessionState.cleanup.deepConfirm.confirmLabel"), cancelLabel: tGlobal("sessionState.cleanup.deepConfirm.cancelLabel"), } ) if (!confirmed) return } const cleanupPromises = Array.from(instanceSessions) .filter(([sessionId]) => sessionId !== excludeSessionId) .map(async ([sessionId, session]) => { const isBlank = await isBlankSession(session, instanceId, fetchIfNeeded) if (!isBlank) return false await deleteSession(instanceId, sessionId).catch((error: Error) => { log.error(`Failed to delete blank session ${sessionId}`, error) }) return true }) if (cleanupPromises.length > 0) { log.info(`Cleaning up ${cleanupPromises.length} blank sessions`) const deletionResults = await Promise.all(cleanupPromises) const deletedCount = deletionResults.filter(Boolean).length if (deletedCount > 0) { showToastNotification({ message: deletedCount === 1 ? tGlobal("sessionState.cleanup.toast.one", { count: deletedCount }) : tGlobal("sessionState.cleanup.toast.other", { count: deletedCount }), variant: "info" }) } } } export { sessions, setSessions, activeSessionId, setActiveSessionId, activeParentSessionId, setActiveParentSessionId, agents, setAgents, providers, setProviders, loading, setLoading, messagesLoaded, setMessagesLoaded, sessionInfoByInstance, setSessionInfoByInstance, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, clearInstanceDraftPrompts, pruneDraftPrompts, withSession, setSessionPendingPermission, setSessionPendingQuestion, setSessionStatus, setActiveSession, setActiveParentSession, clearActiveParentSession, getActiveSession, getActiveParentSession, getSessions, getParentSessions, getChildSessions, getSessionFamily, getSessionThreads, getVisibleSessionIds, isSessionParentExpanded, setSessionParentExpanded, toggleSessionParentExpanded, ensureSessionParentExpanded, setActiveSessionFromList, isSessionBusy, isSessionMessagesLoading, getSessionInfo, isBlankSession, cleanupBlankSessions, }