import { 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" 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 } 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()) 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) { 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) { 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 setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void { withSession(instanceId, sessionId, (session) => { const time = { ...(session.time ?? {}) } as Session["time"] & { compacting?: number | boolean; updated?: number } const compactingFlag = time.compacting const wasCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag) const shouldAlreadyBeCompacting = isCompacting const isAlreadyCorrect = wasCompacting === isCompacting && (shouldAlreadyBeCompacting ? session.status === "compacting" : session.status !== "compacting") if (isAlreadyCorrect) { return false } if (wasCompacting !== isCompacting) { time.compacting = isCompacting ? Date.now() : 0 time.updated = Date.now() } session.time = time if (isCompacting) { session.status = "compacting" } else if (session.status === "compacting") { session.status = "idle" } }) } function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void { withSession(instanceId, sessionId, (session) => { if (session.pendingPermission === pending) return false session.pendingPermission = 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 { withSession(instanceId, sessionId, (session) => { if (session.status === status) return false session.status = status }) } 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] } 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 { messages = await requestData( instance.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( "This cleanup may be slow, and may delete sessions you didn't intend to delete. Are you sure?", { title: "Deep Clean Sessions", detail: "Deep Clean Sessions will delete all sessions that have no messages, remove any finished sub-agent sessions, and clear out any unused forks of a session.", confirmLabel: "Continue", cancelLabel: "Cancel" } ) 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: `Cleaned up ${deletedCount} blank session${deletedCount === 1 ? "" : "s"}`, 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, setSessionCompactionState, setSessionPendingPermission, setSessionStatus, setActiveSession, setActiveParentSession, clearActiveParentSession, getActiveSession, getActiveParentSession, getSessions, getParentSessions, getChildSessions, getSessionFamily, isSessionBusy, isSessionMessagesLoading, getSessionInfo, isBlankSession, cleanupBlankSessions, }