2026-03-31 18:47:00 +03:00
/**
* Session UI Store — ephemeral UI state only.
*
* Domain data (sessions, messages, parts, permissions, questions, status)
* lives in sync child stores. This store owns ONLY transient UI concerns:
* current selection, draft state, viewport anchors, model/agent preferences,
* voice state, abort prompts, attached files, worktree metadata.
*
2026-04-17 01:13:59 +08:00
* Session↔worktree attachments are the authoritative exception: they live in
* session-worktree-store (shared sync), and session-ui-store routes through it.
*
2026-03-31 18:47:00 +03:00
* SDK-calling actions that need domain data read it from sync-refs.
*/
import { create } from "zustand"
2026-04-01 00:06:10 -07:00
import type { Session , Part , Message , TextPart } from "@opencode-ai/sdk/v2/client"
2026-04-17 01:13:59 +08:00
import type { AttachedFile , SessionContextUsage , SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
2026-03-31 18:47:00 +03:00
import type { WorktreeMetadata } from "@/types/worktree"
import { opencodeClient } from "@/lib/opencode/client"
2026-06-02 00:43:05 +03:00
import { runtimeFetch } from "@/lib/runtime-fetch"
2026-03-31 18:47:00 +03:00
import { useConfigStore } from "@/stores/useConfigStore"
import { useProjectsStore } from "@/stores/useProjectsStore"
2026-05-13 03:12:30 -05:00
import { useGlobalSessionsStore , resolveGlobalSessionDirectory } from "@/stores/useGlobalSessionsStore"
2026-03-31 18:47:00 +03:00
import { useDirectoryStore } from "@/stores/useDirectoryStore"
import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore"
import { useCommandsStore } from "@/stores/useCommandsStore"
2026-06-23 01:51:05 -07:00
import { useSkillsStore } from "@/stores/useSkillsStore"
2026-06-30 04:47:52 -04:00
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
2026-03-31 18:47:00 +03:00
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
2026-07-13 09:43:35 +11:00
import { normalizePath } from "@/lib/pathNormalization"
2026-03-31 18:47:00 +03:00
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
2026-06-02 12:53:30 +03:00
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
2026-03-31 18:47:00 +03:00
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
2026-06-26 19:52:39 +11:00
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
2026-06-26 20:16:42 +11:00
import { getWorktreeSetupWaitEnabled } from "@/lib/openchamberConfig"
2026-04-18 16:05:55 +03:00
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
2026-03-31 18:47:00 +03:00
import {
getSyncSessions ,
getAllSyncSessions ,
getSyncMessages ,
getSyncParts ,
getDirectoryState ,
} from "./sync-refs"
import { markSessionViewed } from "./notification-store"
import { setActiveSession } from "./sync-context"
import {
createSession as createSessionAction ,
deleteSession as deleteSessionAction ,
archiveSession as archiveSessionAction ,
updateSessionTitle as updateSessionTitleAction ,
shareSession as shareSessionAction ,
unshareSession as unshareSessionAction ,
optimisticSend ,
2026-05-08 14:53:48 +03:00
refetchSessionMessages ,
2026-06-02 00:43:05 +03:00
revertToMessage as revertToMessageAction ,
unrevertSession as unrevertSessionAction ,
forkFromMessage as forkFromMessageAction ,
2026-06-18 08:43:16 +11:00
fetchMessagesForSession ,
2026-03-31 18:47:00 +03:00
} from "./session-actions"
import { useInputStore , type SyntheticContextPart } from "./input-store"
2026-07-12 01:23:22 +03:00
import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore"
import { setSessionGoal } from "@/lib/sessionGoalActions"
import { wrapSystemReminder } from "@/lib/systemReminder"
import { useUIStore } from "@/stores/useUIStore"
2026-03-31 18:47:00 +03:00
import { useSelectionStore } from "./selection-store"
2026-06-02 00:43:05 +03:00
import { getViewportSessionMemory , useViewportStore , viewportSessionKey } from "./viewport-store"
2026-04-17 01:13:59 +08:00
import { useSessionWorktreeStore } from "./session-worktree-store"
2026-04-16 20:18:08 +03:00
import { getAttachedSessionDirectory } from "./session-worktree-contract"
2026-06-02 00:43:05 +03:00
import { setSessionOpener } from "./session-navigation"
import { getRuntimeKey } from "@/lib/runtime-switch"
2026-07-21 20:52:20 +03:00
import { persistWorktreeTopology , readPersistedWorktreeTopology } from "./worktree-topology-cache"
2026-06-02 00:43:05 +03:00
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
2026-03-31 18:47:00 +03:00
export type { AttachedFile }
// ---------------------------------------------------------------------------
// Send routing — shell mode, slash commands, or normal prompt
// ---------------------------------------------------------------------------
2026-05-25 16:00:48 +03:00
export function routeMessage ( params : {
2026-03-31 18:47:00 +03:00
sessionId : string
2026-05-25 00:29:52 +03:00
directory? : string | null
2026-03-31 18:47:00 +03:00
content : string
providerID : string
modelID : string
agent? : string
2026-05-14 15:30:10 +03:00
agentMentionName? : string
2026-03-31 18:47:00 +03:00
variant? : string
inputMode ?: "normal" | "shell"
files? : Array < { type : "file" ; mime : string ; url : string ; filename : string } >
additionalParts? : Array < { text : string ; synthetic? : boolean ; files? : Array < { type : "file" ; mime : string ; url : string ; filename : string } > } >
2026-06-29 09:28:20 +11:00
delivery ?: 'steer'
2026-03-31 18:47:00 +03:00
}) : Promise < void > {
2026-06-02 00:43:05 +03:00
const requestDirectory = params . directory ?? undefined
if ( params . inputMode === "shell" ) {
return opencodeClient . shellSession ({
sessionId : params.sessionId ,
directory : requestDirectory ,
agent : params.agent ?? "" ,
model : { providerID : params.providerID , modelID : params.modelID },
command : params.content ,
}). then (() => undefined )
}
2026-03-31 18:47:00 +03:00
2026-06-02 00:43:05 +03:00
// Slash commands — fire and forget, SSE delivers messages and status
if ( params . content . startsWith ( "/" )) {
const [ head , ... tail ] = params . content . split ( " " )
const cmdName = head . slice ( 1 )
2026-03-31 18:47:00 +03:00
2026-06-02 00:43:05 +03:00
const dirState = getDirectoryState ( requestDirectory )
const syncCommands = dirState ? . command ?? []
const storeCommands = useCommandsStore . getState (). commands
2026-03-31 18:47:00 +03:00
2026-06-23 01:51:05 -07:00
// OpenCode registers every skill as a command (source: "skill"), but the
// commands store filters skills out and the synced command list is only
// hydrated at bootstrap. Consult the live skills store so a skill selected
// from the slash menu is invoked via session.command (injecting its
// content) instead of being sent as a literal "/name" message (#1605).
2026-06-02 00:43:05 +03:00
const isCommand = syncCommands . find (( c ) => c . name === cmdName )
|| storeCommands . find (( c ) => c . name === cmdName )
2026-06-23 01:51:05 -07:00
|| useSkillsStore . getState (). skills . some (( s ) => s . name === cmdName )
2026-03-31 18:47:00 +03:00
2026-06-02 00:43:05 +03:00
if ( isCommand ) {
return optimisticSend ({
sessionId : params.sessionId ,
content : params.content ,
providerID : params.providerID ,
modelID : params.modelID ,
agent : params.agent ,
2026-06-04 13:32:16 +03:00
directory : requestDirectory ,
2026-06-02 00:43:05 +03:00
files : params.files ,
send : ( messageID ) => opencodeClient . sendCommand ({
id : params.sessionId ,
2026-04-27 05:09:56 -04:00
providerID : params.providerID ,
modelID : params.modelID ,
2026-06-02 00:43:05 +03:00
command : cmdName ,
arguments : tail.join ( " " ),
2026-04-27 05:09:56 -04:00
agent : params.agent ,
2026-06-02 00:43:05 +03:00
variant : params.variant ,
2026-04-27 05:09:56 -04:00
files : params.files ,
2026-06-02 00:43:05 +03:00
messageId : messageID ,
directory : requestDirectory ,
}). then (() => {}),
})
2026-03-31 18:47:00 +03:00
}
2026-06-02 00:43:05 +03:00
}
2026-03-31 18:47:00 +03:00
2026-06-02 00:43:05 +03:00
// Normal prompt — optimistic insert so message appears instantly
return optimisticSend ({
sessionId : params.sessionId ,
content : params.content ,
providerID : params.providerID ,
modelID : params.modelID ,
agent : params.agent ,
2026-06-04 13:32:16 +03:00
directory : requestDirectory ,
2026-06-02 00:43:05 +03:00
files : params.files ,
send : ( messageID ) => opencodeClient . sendMessage ({
id : params.sessionId ,
2026-03-31 18:47:00 +03:00
providerID : params.providerID ,
modelID : params.modelID ,
2026-06-02 00:43:05 +03:00
text : params.content ,
2026-03-31 18:47:00 +03:00
agent : params.agent ,
2026-06-02 00:43:05 +03:00
agentMentions : params.agentMentionName ? [{ name : params.agentMentionName }] : undefined ,
variant : params.variant ,
2026-03-31 18:47:00 +03:00
files : params.files ,
2026-06-02 00:43:05 +03:00
additionalParts : params.additionalParts ,
2026-06-29 09:28:20 +11:00
delivery : params.delivery ,
2026-06-02 00:43:05 +03:00
messageId : messageID ,
directory : requestDirectory ,
}). then (() => {}),
})
2026-05-25 00:29:52 +03:00
}
type SendMessageOptions = {
sessionId? : string
2026-07-21 20:52:20 +03:00
directory? : string
2026-06-29 09:28:20 +11:00
delivery ?: 'steer'
2026-03-31 18:47:00 +03:00
}
2026-06-06 23:22:16 +03:00
type AssistantMessageSessionExecution = {
providerID : string
modelID : string
variant : string
agent : string
instructions : string
createWorktree? : boolean
2026-07-12 01:23:22 +03:00
runAsGoal? : boolean
2026-06-06 23:22:16 +03:00
}
2026-04-27 05:09:56 -04:00
function notifyMessageSent ( sessionId : string ) : void {
2026-06-02 00:43:05 +03:00
runtimeFetch ( `/api/sessions/ ${ sessionId } /message-sent` , { method : "POST" })
2026-04-27 05:09:56 -04:00
. catch (() => { /* ignore */ })
}
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type { SyntheticContextPart } from "./input-store"
export type { SessionMemoryState } from "./viewport-store"
export type NewSessionDraftState = {
open : boolean
selectedProjectId? : string | null
directoryOverride : string | null
2026-07-11 23:15:02 +11:00
permissionAutoAcceptEnabled? : boolean
2026-03-31 18:47:00 +03:00
pendingWorktreeRequestId? : string | null
bootstrapPendingDirectory? : string | null
preserveDirectoryOverride? : boolean
parentID : string | null
title? : string
initialPrompt? : string
syntheticParts? : SyntheticContextPart []
targetFolderId? : string
}
export type ViewportAnchor = {
sessionId : string
value : number
}
export type SessionHistoryMeta = {
limit : number
hasMore : boolean
complete : boolean
isLoading : boolean
loading? : boolean
nextCursor? : string
}
export type SessionUIState = {
currentSessionId : string | null
2026-06-03 22:38:15 +03:00
currentSessionDirectory : string | null
2026-03-31 18:47:00 +03:00
newSessionDraft : NewSessionDraftState
abortPromptSessionId : string | null
abortPromptExpiresAt : number | null
error : string | null
worktreeMetadata : Map < string , WorktreeMetadata >
availableWorktrees : WorktreeMetadata []
availableWorktreesByProject : Map < string , WorktreeMetadata [] >
webUICreatedSessions : Set < string >
sessionAbortFlags : Map < string , { timestamp : number ; acknowledged : boolean }>
abortControllers : Map < string , AbortController >
isLoading : boolean
lastLoadedDirectory : string | null
2026-04-06 20:44:13 +03:00
// Plan mode - per-session plan file availability (set when plan_enter tool creates a plan)
sessionPlanAvailable : Map < string , boolean >
markSessionPlanAvailable : ( sessionId : string ) => void
isSessionPlanAvailable : ( sessionId : string ) => boolean
2026-03-31 18:47:00 +03:00
2026-04-22 03:34:06 +08:00
// Non-Git mode: dismissed signature hash per session, hides bar until new turn arrives
pendingChangesBarDismissed : Map < string , string >
dismissPendingChangesBar : ( sessionId : string , signature : string | null ) => void
2026-03-31 18:47:00 +03:00
// Actions — UI state management
setCurrentSession : ( id : string | null , directoryHint? : string | null ) => void
2026-06-02 00:43:05 +03:00
prepareForRuntimeSwitch : ( apiBaseUrl? : string | null ) => void
restoreForRuntimeSwitch : ( apiBaseUrl? : string | null ) => void
2026-03-31 18:47:00 +03:00
openNewSessionDraft : ( options? : Partial < NewSessionDraftState >) => void
closeNewSessionDraft : () => void
setNewSessionDraftTarget : ( target : { projectId? : string | null ; selectedProjectId? : string | null ; directoryOverride? : string | null }, options ?: { force? : boolean }) => void
setDraftPreserveDirectoryOverride : ( value : boolean ) => void
2026-07-11 23:15:02 +11:00
setDraftPermissionAutoAcceptEnabled : ( enabled : boolean ) => void
2026-03-31 18:47:00 +03:00
acknowledgeSessionAbort : ( sessionId : string ) => void
clearAbortPrompt : () => void
armAbortPrompt : ( durationMs? : number ) => number | null
clearError : () => void
markSessionAsOpenChamberCreated : ( sessionId : string ) => void
isOpenChamberCreatedSession : ( sessionId : string ) => boolean
getContextUsage : ( contextLimit : number , outputLimit : number ) => SessionContextUsage | null
initializeNewOpenChamberSession : ( sessionId : string , agents : unknown []) => void
setWorktreeMetadata : ( sessionId : string , metadata : WorktreeMetadata | null ) => void
overrideNewSessionDraftTarget : ( options : Record < string , unknown >) => void
resolvePendingDraftWorktreeTarget : ( requestId : string , directory : string | null , options? : Record < string , unknown >) => void
setDraftBootstrapPendingDirectory : ( directory : string | null ) => void
setPendingDraftWorktreeRequest : ( requestId : string | null ) => void
getWorktreeMetadata : ( sessionId : string ) => WorktreeMetadata | undefined
// Actions — SDK-calling operations (read domain data from sync-refs)
sendMessage : (
content : string ,
providerID : string ,
modelID : string ,
agent? : string ,
attachments? : AttachedFile [],
agentMentionName? : string ,
additionalParts? : Array < { text : string ; attachments? : AttachedFile []; synthetic? : boolean } > ,
variant? : string ,
inputMode ?: "normal" | "shell" ,
2026-05-25 00:29:52 +03:00
options? : SendMessageOptions ,
2026-03-31 18:47:00 +03:00
) => Promise < void >
2026-06-07 01:22:40 +03:00
createSession : ( title? : string , directoryOverride? : string | null , parentID? : string | null , metadata? : Record < string , unknown >) => Promise < Session | null >
2026-03-31 18:47:00 +03:00
deleteSession : ( id : string , options? : Record < string , unknown >) => Promise < boolean >
deleteSessions : ( ids : string [], options? : Record < string , unknown >) => Promise < { deletedIds : string []; failedIds : string [] } >
archiveSession : ( id : string ) => Promise < boolean >
archiveSessions : ( ids : string [], options? : Record < string , unknown >) => Promise < { archivedIds : string []; failedIds : string [] } >
updateSessionTitle : ( sessionId : string , title : string ) => Promise < void >
shareSession : ( sessionId : string ) => Promise < Session | null >
unshareSession : ( sessionId : string ) => Promise < Session | null >
2026-05-16 21:44:37 +08:00
revertToMessage : ( sessionId : string , messageId : string , options ?: { skipRedoPush? : boolean }) => Promise < void >
2026-03-31 18:47:00 +03:00
forkFromMessage : ( sessionId : string , messageId : string ) => Promise < void >
handleSlashUndo : ( sessionId : string ) => Promise < void >
2026-05-16 21:44:37 +08:00
handleSlashRedo : ( sessionId : string , options ?: { fullUnrevert? : boolean }) => Promise < void >
2026-06-06 23:22:16 +03:00
createSessionFromAssistantMessage : ( sourceMessageId : string , execution : AssistantMessageSessionExecution ) => Promise < void >
2026-03-31 18:47:00 +03:00
// Data access helpers (read from sync)
getSessionsByDirectory : ( directory : string ) => Session []
getDirectoryForSession : ( sessionId : string ) => string | null
getLastUserChoice : ( sessionId : string ) => { agent? : string ; providerID? : string ; modelID? : string ; variant? : string } | null
getCurrentAgent : ( sessionId : string ) => string | undefined
debugSessionMessages : ( sessionId : string ) => Promise < void >
pollForTokenUpdates : () => void
setSessionDirectory : ( sessionId : string , directory : string | null ) => void
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const resolveDirectoryKey = ( session : Session ) : string | null => {
const sessionRecord = session as Session & {
directory? : string | null
project ?: { worktree? : string | null } | null
}
return normalizePath ( sessionRecord . directory ?? null )
?? normalizePath ( sessionRecord . project ? . worktree ?? null )
}
2026-06-30 04:47:52 -04:00
const safeStorage = getDeferredSafeStorage ()
2026-03-31 18:47:00 +03:00
const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget"
type PersistedDraftTarget = { projectId : string | null ; directory : string | null }
const readPersistedDraftTarget = () : PersistedDraftTarget | null => {
try {
const raw = safeStorage . getItem ( DRAFT_TARGET_STORAGE_KEY )
if ( ! raw ) return null
const parsed = JSON . parse ( raw ) as { projectId? : unknown ; directory? : unknown }
return {
projectId : typeof parsed ? . projectId === "string" ? parsed.projectId : null ,
directory : normalizePath ( typeof parsed ? . directory === "string" ? parsed.directory : null ),
}
} catch {
return null
}
}
const persistDraftTarget = ( target : PersistedDraftTarget ) : void => {
try {
safeStorage . setItem ( DRAFT_TARGET_STORAGE_KEY , JSON . stringify ( target ))
} catch { /* ignored */ }
}
2026-04-18 16:05:55 +03:00
const resolveDraftProjectForDirectory = resolveProjectForSessionDirectory
2026-03-31 18:47:00 +03:00
2026-04-17 01:13:59 +08:00
const getAttachmentForSession = ( sessionId : string | null | undefined ) : SessionWorktreeAttachment | undefined => {
if ( ! sessionId ) return undefined
return useSessionWorktreeStore . getState (). getAttachment ( sessionId )
}
2026-03-31 18:47:00 +03:00
const resolveSessionDirectory = (
sessionId : string | null | undefined ,
getWtMeta : ( id : string ) => WorktreeMetadata | undefined ,
) : string | null => {
if ( ! sessionId ) return null
2026-04-17 01:13:59 +08:00
const attachmentDirectory = getAttachedSessionDirectory ( getAttachmentForSession ( sessionId ))
if ( attachmentDirectory ) return attachmentDirectory
2026-03-31 18:47:00 +03:00
const metaPath = getWtMeta ( sessionId ) ? . path
if ( typeof metaPath === "string" && metaPath . trim (). length > 0 ) return normalizePath ( metaPath )
2026-06-03 22:38:15 +03:00
const runtimeMemory = runtimeSessionMemory . get ( runtimeMemoryKey ())
if ( runtimeMemory ? . sessionId === sessionId && runtimeMemory . directory ) {
return normalizePath ( runtimeMemory . directory )
}
2026-03-31 18:47:00 +03:00
const sessions = getAllSyncSessions ()
const target = sessions . find (( s ) => s . id === sessionId )
if ( ! target ) return null
return resolveDirectoryKey ( target )
}
2026-04-01 00:06:10 -07:00
const activateConfigForDirectory = async ( directory : string | null | undefined ) : Promise < void > => {
await useConfigStore . getState (). activateDirectory ( normalizePath ( directory ))
}
2026-03-31 18:47:00 +03:00
const DEFAULT_DRAFT : NewSessionDraftState = {
open : false ,
directoryOverride : null ,
parentID : null ,
}
2026-06-02 00:43:05 +03:00
const activeSessionByRuntime = new Map < string , string | null >()
type RuntimeSessionMemory = {
sessionId : string | null
directory : string | null
draft : NewSessionDraftState
2026-07-21 20:52:20 +03:00
worktreeMetadata : Map < string , WorktreeMetadata >
availableWorktreesByProject : Map < string , WorktreeMetadata [] >
2026-06-02 00:43:05 +03:00
}
const runtimeSessionMemory = new Map < string , RuntimeSessionMemory >()
const runtimeMemoryKey = ( value? : string | null ) : string => {
const key = ( value ?? getRuntimeKey ()). trim ()
return key || "default"
}
const cloneDraft = ( draft : NewSessionDraftState ) : NewSessionDraftState => ({ ... draft })
const writeRuntimeSessionMemory = ( key : string , patch : Partial < RuntimeSessionMemory >) : void => {
const current = runtimeSessionMemory . get ( key )
runtimeSessionMemory . set ( key , {
sessionId : current?.sessionId ?? null ,
directory : current?.directory ?? null ,
draft : current?.draft ? cloneDraft ( current . draft ) : { ... DEFAULT_DRAFT },
2026-07-21 20:52:20 +03:00
worktreeMetadata : current?.worktreeMetadata ?? new Map (),
availableWorktreesByProject : current?.availableWorktreesByProject ?? new Map (),
2026-06-02 00:43:05 +03:00
... patch ,
})
}
2026-06-26 19:52:39 +11:00
type MaterializedDraftSession = {
sessionId : string
directory : string | null
agent? : string
syntheticParts? : SyntheticContextPart []
}
2026-06-26 20:16:42 +11:00
const resolveProjectRefForWorktreeDirectory = ( directory : string | null , projectId? : string | null ) : { id : string ; path : string } | null => {
const projectsState = useProjectsStore . getState ()
if ( projectId ) {
const project = projectsState . projects . find (( entry ) => entry . id === projectId )
if ( project ? . path ) return { id : project.id , path : project.path }
}
const resolved = resolveProjectForSessionDirectory ( projectsState . projects , useSessionUIStore . getState (). availableWorktreesByProject , directory )
return resolved ? . path ? { id : resolved.id , path : resolved.path } : null
}
const waitForWorktreeBootstrapIfConfigured = async ( directory : string | null , projectId? : string | null ) : Promise < void > => {
if ( ! directory ) return
const project = resolveProjectRefForWorktreeDirectory ( directory , projectId )
if ( project && await getWorktreeSetupWaitEnabled ( project )) {
await waitForWorktreeBootstrap ( directory )
}
}
2026-06-26 19:52:39 +11:00
export async function materializeOpenDraftSession ( selection : {
providerID : string
modelID : string
agent? : string
variant? : string
}) : Promise < MaterializedDraftSession | null > {
const store = useSessionUIStore . getState ()
const draft = store . newSessionDraft
if ( ! draft ? . open ) return null
2026-07-11 23:15:02 +11:00
const draftPermissionAutoAcceptEnabled = draft . permissionAutoAcceptEnabled === true
2026-06-26 19:52:39 +11:00
const trimmedAgent = typeof selection . agent === "string" && selection . agent . trim (). length > 0
? selection . agent . trim ()
: undefined
let draftDirectoryOverride = draft . bootstrapPendingDirectory ?? draft . directoryOverride ?? null
const draftProjectId = draft . selectedProjectId ?? null
if ( draft . pendingWorktreeRequestId ) {
draftDirectoryOverride = await waitForPendingDraftWorktreeRequest ( draft . pendingWorktreeRequestId )
store . resolvePendingDraftWorktreeTarget ( draft . pendingWorktreeRequestId , draftDirectoryOverride )
}
2026-06-26 20:16:42 +11:00
await waitForWorktreeBootstrapIfConfigured ( draftDirectoryOverride , draftProjectId )
2026-06-26 19:52:39 +11:00
const created = await store . createSession ( draft . title , draftDirectoryOverride , draft . parentID ?? null )
if ( ! created ? . id ) throw new Error ( "Failed to create session" )
persistDraftTarget ({
projectId : draftProjectId ,
directory : normalizePath ( draftDirectoryOverride ?? created . directory ?? null ),
})
const draftSyntheticParts = draft . syntheticParts
const createdDirectory = normalizePath ( draftDirectoryOverride ?? created . directory ?? null )
const configState = useConfigStore . getState ()
void activateConfigForDirectory ( createdDirectory ). catch (( error ) => {
console . warn ( "Failed to activate directory after creating session:" , error )
})
const effectiveDraftAgent = trimmedAgent ?? configState . currentAgentName
useSelectionStore . getState (). saveSessionModelSelection ( created . id , selection . providerID , selection . modelID )
if ( effectiveDraftAgent ) {
useSelectionStore . getState (). saveSessionAgentSelection ( created . id , effectiveDraftAgent )
useSelectionStore . getState (). saveAgentModelForSession ( created . id , effectiveDraftAgent , selection . providerID , selection . modelID )
useSelectionStore . getState (). saveAgentModelVariantForSession ( created . id , effectiveDraftAgent , selection . providerID , selection . modelID , selection . variant )
}
store . initializeNewOpenChamberSession ( created . id , configState . agents ?? [])
store . setCurrentSession ( created . id , createdDirectory )
2026-07-12 00:54:46 +03:00
if ( draftPermissionAutoAcceptEnabled ) {
void import ( "@/stores/permissionStore" )
. then (({ usePermissionStore }) => usePermissionStore . getState (). setSessionAutoAccept ( created . id , true ))
. catch (( error ) => {
console . warn ( "Failed to apply draft permission auto-accept to new session:" , error )
})
}
2026-06-26 19:52:39 +11:00
return {
sessionId : created.id ,
directory : createdDirectory ,
agent : effectiveDraftAgent ,
syntheticParts : draftSyntheticParts ,
}
}
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
2026-06-15 03:16:34 +03:00
// ---------------------------------------------------------------------------
// Persisted worktree map (stale-while-revalidate)
//
// Worktree discovery is async (git), so the worktree→project map isn't ready at
// startup. Persist it so (a) the sidebar worktree list paints instantly, and
// (b) useConfigStore.resolveConfigDirectory can map a worktree to its project on
// the FIRST launch — yielding a single project-scoped config load instead of a
// worktree+project double-load. Discovery refreshes it in the background.
// ---------------------------------------------------------------------------
const flattenWorktreeMap = ( map : Map < string , WorktreeMetadata [] >) : WorktreeMetadata [] => {
const out : WorktreeMetadata [] = []
for ( const list of map . values ()) out . push (... list )
return out
}
2026-07-21 20:52:20 +03:00
const PERSISTED_WORKTREE_MAP = readPersistedWorktreeTopology ( runtimeMemoryKey ())
2026-06-15 03:16:34 +03:00
2026-03-31 18:47:00 +03:00
export const useSessionUIStore = create < SessionUIState >()(( set , get ) => ({
currentSessionId : null ,
2026-06-03 22:38:15 +03:00
currentSessionDirectory : null ,
2026-03-31 18:47:00 +03:00
newSessionDraft : { ... DEFAULT_DRAFT },
abortPromptSessionId : null ,
abortPromptExpiresAt : null ,
error : null ,
worktreeMetadata : new Map (),
2026-06-15 03:16:34 +03:00
availableWorktrees : flattenWorktreeMap ( PERSISTED_WORKTREE_MAP ),
availableWorktreesByProject : PERSISTED_WORKTREE_MAP ,
2026-03-31 18:47:00 +03:00
webUICreatedSessions : new Set (),
sessionAbortFlags : new Map (),
abortControllers : new Map (),
isLoading : false ,
lastLoadedDirectory : null ,
2026-04-06 20:44:13 +03:00
sessionPlanAvailable : new Map (),
2026-04-22 03:34:06 +08:00
pendingChangesBarDismissed : new Map (),
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
// setCurrentSession
// ---------------------------------------------------------------------------
setCurrentSession : ( id , directoryHint? : string | null ) => {
if ( id ) {
get (). closeNewSessionDraft ()
}
2026-06-02 00:43:05 +03:00
const key = runtimeMemoryKey ()
activeSessionByRuntime . set ( key , id )
2026-03-31 18:47:00 +03:00
const previousSessionId = get (). currentSessionId
const directoryState = useDirectoryStore . getState ()
const sessionDir = resolveSessionDirectory (
id ,
( sid ) => get (). worktreeMetadata . get ( sid ),
)
const fallbackDir = opencodeClient . getDirectory () ?? directoryState . currentDirectory ?? null
const resolvedDir = ( directoryHint ? normalizePath ( directoryHint ) : null ) ?? sessionDir ?? fallbackDir
2026-06-10 02:24:47 +03:00
const projectsState = useProjectsStore . getState ()
const sessionProject = resolvedDir
? resolveProjectForSessionDirectory (
projectsState . projects ,
get (). availableWorktreesByProject ,
resolvedDir ,
)
: null
2026-06-03 22:38:15 +03:00
// Set the directory together with the session id so chat hooks read the
// same child store that send/SSE events will update during startup races.
set ({ currentSessionId : id , currentSessionDirectory : id ? resolvedDir ?? null : null })
2026-06-02 00:43:05 +03:00
writeRuntimeSessionMemory ( key , { sessionId : id , directory : resolvedDir ?? null })
2026-03-31 18:47:00 +03:00
2026-06-18 08:43:16 +11:00
// Kick off the message fetch on the same tick, before React commits the
// state change and fires ChatContainer.useEffect. The fetch is
// fire-and-forget — any transient failure gets retried by the reactive path.
if ( id ) {
void fetchMessagesForSession ( id , resolvedDir )
}
2026-03-31 18:47:00 +03:00
try {
if ( resolvedDir && directoryState . currentDirectory !== resolvedDir ) {
directoryState . setDirectory ( resolvedDir , { showOverlay : false })
}
2026-06-10 02:24:47 +03:00
if ( sessionProject && projectsState . activeProjectId !== sessionProject . id ) {
projectsState . setActiveProjectIdOnly ( sessionProject . id )
}
2026-03-31 18:47:00 +03:00
opencodeClient . setDirectory ( resolvedDir ?? undefined )
} catch ( e ) {
console . warn ( "Failed to set OpenCode directory for session switch:" , e )
}
2026-04-26 16:24:07 +03:00
// Defer viewport anchor save for previous session — not needed for the
// skeleton to render and reads messages which can be expensive.
2026-03-31 18:47:00 +03:00
if ( previousSessionId && previousSessionId !== id ) {
2026-04-26 16:24:07 +03:00
const prevId = previousSessionId
setTimeout (() => {
2026-06-02 00:43:05 +03:00
const memState = getViewportSessionMemory ( prevId )
2026-04-26 16:24:07 +03:00
if ( ! memState ? . isStreaming ) {
const prevMessages = getSyncMessages ( prevId )
if ( prevMessages . length > 0 ) {
useViewportStore . getState (). updateViewportAnchor ( prevId , prevMessages . length - 1 )
}
2026-03-31 18:47:00 +03:00
}
2026-04-26 16:24:07 +03:00
}, 0 )
2026-03-31 18:47:00 +03:00
}
// Mark session viewed in notification store + update active session ref
if ( id ) {
markSessionViewed ( id )
setActiveSession ( resolvedDir ?? "" , id )
}
},
2026-06-02 00:43:05 +03:00
prepareForRuntimeSwitch : ( apiBaseUrl? : string | null ) => {
const key = runtimeMemoryKey ( apiBaseUrl )
const directory = useDirectoryStore . getState (). currentDirectory || null
const currentSessionId = get (). currentSessionId
const directorySnapshot = directory ? getDirectoryState ( directory ) : null
rememberRuntimeLiveStatus ({
runtimeKey : key ,
directory ,
sessionId : currentSessionId ,
status : currentSessionId ? directorySnapshot ? . session_status ? .[ currentSessionId ] : null ,
})
activeSessionByRuntime . set ( key , get (). currentSessionId )
writeRuntimeSessionMemory ( key , {
sessionId : currentSessionId ,
directory ,
draft : cloneDraft ( get (). newSessionDraft ),
2026-07-21 20:52:20 +03:00
worktreeMetadata : new Map ( get (). worktreeMetadata ),
availableWorktreesByProject : new Map ( get (). availableWorktreesByProject ),
2026-06-02 00:43:05 +03:00
})
},
restoreForRuntimeSwitch : ( apiBaseUrl? : string | null ) => {
const key = runtimeMemoryKey ( apiBaseUrl )
const memory = runtimeSessionMemory . get ( key )
const restoredSessionId = memory ? . sessionId ?? activeSessionByRuntime . get ( key ) ?? null
const restoredDraft = memory ? . draft ? cloneDraft ( memory . draft ) : { ... DEFAULT_DRAFT }
const restoredDirectory = memory ? . directory ?? null
2026-07-21 20:52:20 +03:00
const availableWorktreesByProject = memory ? . availableWorktreesByProject
?? readPersistedWorktreeTopology ( key )
2026-06-02 00:43:05 +03:00
if ( restoredDirectory ) {
useDirectoryStore . getState (). setDirectory ( restoredDirectory , { showOverlay : false })
}
set ({
currentSessionId : restoredSessionId ,
2026-06-03 22:38:15 +03:00
currentSessionDirectory : restoredSessionId ? restoredDirectory : null ,
2026-06-02 00:43:05 +03:00
newSessionDraft : restoredSessionId ? { ... DEFAULT_DRAFT } : restoredDraft ,
abortPromptSessionId : null ,
abortPromptExpiresAt : null ,
error : null ,
2026-07-21 20:52:20 +03:00
worktreeMetadata : memory?.worktreeMetadata ?? new Map (),
availableWorktrees : flattenWorktreeMap ( availableWorktreesByProject ),
availableWorktreesByProject ,
2026-06-02 00:43:05 +03:00
sessionAbortFlags : new Map (),
pendingChangesBarDismissed : new Map (),
})
if ( restoredSessionId ) {
2026-06-03 22:38:15 +03:00
setActiveSession ( restoredDirectory ?? opencodeClient . getDirectory () ?? "" , restoredSessionId )
2026-06-02 00:43:05 +03:00
} else {
setActiveSession ( "" , "" )
}
},
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
// openNewSessionDraft
// ---------------------------------------------------------------------------
openNewSessionDraft : ( options ) => {
const projectsState = useProjectsStore . getState ()
const projects = projectsState . projects
const availableWorktreesByProject = get (). availableWorktreesByProject
const activeProject = projectsState . getActiveProject ()
const currentDirectory = normalizePath ( useDirectoryStore . getState (). currentDirectory ?? null )
const persistedTarget = readPersistedDraftTarget ()
const explicitDirectory = options ? . directoryOverride !== undefined
? normalizePath ( options . directoryOverride )
: null
const explicitProject = options ? . selectedProjectId
? projects . find (( p ) => p . id === options . selectedProjectId ) ?? null
: null
const inferredProjectFromDir = resolveDraftProjectForDirectory ( projects , availableWorktreesByProject , explicitDirectory )
const fallbackProject = (() => {
if ( activeProject ) return activeProject
if ( projectsState . activeProjectId ) return projects . find (( p ) => p . id === projectsState . activeProjectId ) ?? null
return projects [ 0 ] ?? null
})()
const persistedProjectById = persistedTarget ? . projectId
? projects . find (( p ) => p . id === persistedTarget . projectId ) ?? null
: null
const persistedProjectByDir = resolveDraftProjectForDirectory ( projects , availableWorktreesByProject , persistedTarget ? . directory ?? null )
const currentDirProject = resolveDraftProjectForDirectory ( projects , availableWorktreesByProject , currentDirectory )
const selectedProject = (() => {
2026-06-24 10:56:54 +03:00
if ( explicitProject ) return explicitProject
if ( explicitDirectory !== null ) return inferredProjectFromDir
if ( currentDirectory ) return currentDirProject
2026-03-31 18:47:00 +03:00
return persistedProjectByDir ?? persistedProjectById ?? fallbackProject
})()
const directory = (() => {
if ( explicitDirectory !== null ) return explicitDirectory
if ( explicitProject ) return normalizePath ( explicitProject . path ?? null )
if ( currentDirectory ) return currentDirectory
if ( persistedTarget ? . directory ) return persistedTarget . directory
return normalizePath ( selectedProject ? . path ?? null )
})()
persistDraftTarget ({ projectId : selectedProject?.id ?? null , directory })
2026-06-02 00:43:05 +03:00
const nextDraft : NewSessionDraftState = {
open : true ,
selectedProjectId : selectedProject?.id ?? null ,
directoryOverride : directory ,
2026-07-11 23:15:02 +11:00
permissionAutoAcceptEnabled : options?.permissionAutoAcceptEnabled === true ,
2026-06-02 00:43:05 +03:00
pendingWorktreeRequestId : options?.pendingWorktreeRequestId ?? null ,
bootstrapPendingDirectory : normalizePath ( options ? . bootstrapPendingDirectory ?? null ),
preserveDirectoryOverride : options?.preserveDirectoryOverride === true ,
parentID : options?.parentID ?? null ,
title : options?.title ,
initialPrompt : options?.initialPrompt ,
syntheticParts : options?.syntheticParts ,
targetFolderId : options?.targetFolderId ,
}
2026-03-31 18:47:00 +03:00
set ({
newSessionDraft : {
2026-06-02 00:43:05 +03:00
... nextDraft ,
2026-03-31 18:47:00 +03:00
},
currentSessionId : null ,
2026-06-03 22:38:15 +03:00
currentSessionDirectory : null ,
2026-03-31 18:47:00 +03:00
error : null ,
})
2026-06-02 00:43:05 +03:00
writeRuntimeSessionMemory ( runtimeMemoryKey (), { sessionId : null , directory , draft : nextDraft })
2026-05-17 15:25:59 +03:00
// Clear composer attachments when opening a new session draft.
// Attachments from the previous session (e.g. restored by revert) must
// not bleed into the new session's input.
useInputStore . getState (). clearAttachedFiles ()
2026-03-31 18:47:00 +03:00
if ( options ? . initialPrompt ) {
useInputStore . getState (). setPendingInputText ( options . initialPrompt )
}
2026-06-14 21:36:05 +03:00
// Config (providers/agents/default model+agent) lives at the PROJECT level. When the user
// came from a worktree session, `directory` is the worktree path, whose provider list does
// not include project/global-scoped providers (e.g. the default agent's non-opencode model)
// — resolving defaults against it would wrongly fall back to opencode/big-pickle. Activate
// the project's config instead so the default cascade matches app startup, then re-apply it
// (a fresh draft must start from defaults, not inherit the previous session's selection).
const configDirectory = normalizePath ( selectedProject ? . path ?? null ) ?? directory
void activateConfigForDirectory ( configDirectory ). then (() => {
2026-07-09 13:54:05 +03:00
useConfigStore . getState (). applyDefaultModelAgentSelection ({
projectDefaultModel : selectedProject?.defaultModel ,
})
2026-06-14 21:36:05 +03:00
})
2026-06-15 04:00:02 -04:00
if ( directory && directory !== useDirectoryStore . getState (). currentDirectory ) {
useDirectoryStore . getState (). setDirectory ( directory )
}
2026-03-31 18:47:00 +03:00
},
// ---------------------------------------------------------------------------
// closeNewSessionDraft
// ---------------------------------------------------------------------------
closeNewSessionDraft : () => {
2026-07-21 20:52:20 +03:00
const currentDraft = get (). newSessionDraft
if (
! currentDraft . open
&& currentDraft . selectedProjectId == null
&& currentDraft . directoryOverride == null
&& currentDraft . pendingWorktreeRequestId == null
&& currentDraft . bootstrapPendingDirectory == null
&& ! currentDraft . preserveDirectoryOverride
&& currentDraft . parentID == null
&& currentDraft . title === undefined
&& currentDraft . initialPrompt === undefined
&& currentDraft . syntheticParts === undefined
&& currentDraft . targetFolderId === undefined
&& currentDraft . permissionAutoAcceptEnabled === undefined
) {
return
}
2026-06-02 00:43:05 +03:00
const nextDraft : NewSessionDraftState = {
2026-03-31 18:47:00 +03:00
open : false ,
selectedProjectId : null ,
directoryOverride : null ,
pendingWorktreeRequestId : null ,
bootstrapPendingDirectory : null ,
preserveDirectoryOverride : false ,
parentID : null ,
title : undefined ,
initialPrompt : undefined ,
syntheticParts : undefined ,
targetFolderId : undefined ,
2026-06-02 00:43:05 +03:00
}
set ({
newSessionDraft : nextDraft ,
2026-03-31 18:47:00 +03:00
})
2026-06-02 00:43:05 +03:00
writeRuntimeSessionMemory ( runtimeMemoryKey (), { draft : nextDraft })
2026-03-31 18:47:00 +03:00
},
2026-04-01 00:06:10 -07:00
setNewSessionDraftTarget : ( target ) => {
let nextDirectory : string | null = null
set (( s ) => {
nextDirectory = normalizePath ( target . directoryOverride ?? s . newSessionDraft . directoryOverride )
return {
newSessionDraft : {
... s . newSessionDraft ,
selectedProjectId : target.projectId ?? target . selectedProjectId ?? s . newSessionDraft . selectedProjectId ,
directoryOverride : target.directoryOverride ?? s . newSessionDraft . directoryOverride ,
},
}
})
void activateConfigForDirectory ( nextDirectory )
2026-06-15 04:00:02 -04:00
if ( nextDirectory && nextDirectory !== useDirectoryStore . getState (). currentDirectory ) {
useDirectoryStore . getState (). setDirectory ( nextDirectory )
}
2026-04-01 00:06:10 -07:00
},
2026-03-31 18:47:00 +03:00
setDraftPreserveDirectoryOverride : ( value ) =>
set (( s ) => {
if ( ! s . newSessionDraft ? . open ) return s
return { newSessionDraft : { ... s . newSessionDraft , preserveDirectoryOverride : value } }
}),
2026-07-11 23:15:02 +11:00
setDraftPermissionAutoAcceptEnabled : ( enabled ) =>
set (( s ) => {
if ( ! s . newSessionDraft ? . open ) return s
return { newSessionDraft : { ... s . newSessionDraft , permissionAutoAcceptEnabled : enabled } }
}),
2026-03-31 18:47:00 +03:00
acknowledgeSessionAbort : ( sessionId ) =>
set (( s ) => {
const flags = new Map ( s . sessionAbortFlags )
const existing = flags . get ( sessionId )
if ( existing ) flags . set ( sessionId , { ... existing , acknowledged : true })
return { sessionAbortFlags : flags }
}),
clearAbortPrompt : () => set ({ abortPromptSessionId : null , abortPromptExpiresAt : null }),
armAbortPrompt : ( durationMs = 5000 ) => {
const { currentSessionId } = get ()
if ( ! currentSessionId ) return null
const expiresAt = Date . now () + durationMs
set ({ abortPromptSessionId : currentSessionId , abortPromptExpiresAt : expiresAt })
return expiresAt
},
clearError : () => set ({ error : null }),
markSessionAsOpenChamberCreated : ( sessionId ) =>
set (( s ) => {
const next = new Set ( s . webUICreatedSessions )
next . add ( sessionId )
return { webUICreatedSessions : next }
}),
isOpenChamberCreatedSession : ( sessionId ) => get (). webUICreatedSessions . has ( sessionId ),
getContextUsage : ( contextLimit : number , outputLimit : number ) => {
if ( get (). newSessionDraft ? . open ) return null
const sessionId = get (). currentSessionId
if ( ! sessionId ) return null
const messages = getSyncMessages ( sessionId )
if ( messages . length === 0 ) return null
type AssistantTokens = { input : number ; output : number ; reasoning : number ; cache : { read : number ; write : number } }
let lastTokens : AssistantTokens | undefined
let lastMessageId : string | undefined
for ( let i = messages . length - 1 ; i >= 0 ; i -- ) {
const msg = messages [ i ]
if ( msg . role !== "assistant" ) continue
const tokens = ( msg as { tokens? : AssistantTokens }). tokens
if ( ! tokens ) continue
const total = tokens . input + tokens . output + tokens . reasoning + ( tokens . cache ? . read ?? 0 ) + ( tokens . cache ? . write ?? 0 )
if ( total > 0 ) {
lastTokens = tokens
lastMessageId = msg . id
break
}
}
if ( ! lastTokens ) return null
const totalTokens = lastTokens . input + lastTokens . output + lastTokens . reasoning + ( lastTokens . cache ? . read ?? 0 ) + ( lastTokens . cache ? . write ?? 0 )
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000
const percentage = contextLimit > 0 ? Math . round (( totalTokens / contextLimit ) * 100 ) : 0
const normalizedOutput = outputLimit > 0 ? Math . round (( lastTokens . output / outputLimit ) * 100 ) : undefined
return {
totalTokens ,
percentage ,
contextLimit : contextLimit || 0 ,
outputLimit : outputLimit || undefined ,
normalizedOutput ,
thresholdLimit ,
lastMessageId ,
}
},
initializeNewOpenChamberSession : () => {
// Stub — was a no-op in old store
},
2026-04-17 01:13:59 +08:00
setWorktreeMetadata : ( sessionId , metadata ) => {
// Write to authoritative session-worktree-store
if ( metadata ) {
useSessionWorktreeStore . getState (). setAttachment ( sessionId , {
worktreeRoot : metadata.worktreeRoot ?? metadata . path ?? null ,
cwd : metadata.path ?? null ,
branch : metadata.branch ?? null ,
headState : metadata.headState ?? ( metadata . branch ? 'branch' : 'detached' ),
worktreeStatus : metadata.worktreeStatus ?? 'ready' ,
worktreeSource : metadata.worktreeSource ?? null ,
legacy : false ,
degraded : false ,
})
} else {
useSessionWorktreeStore . getState (). clearAttachment ( sessionId )
}
// Also keep local map for backward compatibility
2026-03-31 18:47:00 +03:00
set (( s ) => {
const map = new Map ( s . worktreeMetadata )
if ( metadata ) map . set ( sessionId , metadata )
else map . delete ( sessionId )
return { worktreeMetadata : map }
2026-04-17 01:13:59 +08:00
})
},
2026-03-31 18:47:00 +03:00
2026-04-01 00:06:10 -07:00
overrideNewSessionDraftTarget : ( options ) => {
let nextDirectory : string | null = null
set (( s ) => {
const nextDraft = { ... s . newSessionDraft , ... options }
nextDirectory = normalizePath (
typeof nextDraft . directoryOverride === "string" ? nextDraft.directoryOverride : null ,
)
return { newSessionDraft : nextDraft }
})
void activateConfigForDirectory ( nextDirectory )
2026-06-15 04:00:02 -04:00
if ( nextDirectory && nextDirectory !== useDirectoryStore . getState (). currentDirectory ) {
useDirectoryStore . getState (). setDirectory ( nextDirectory )
}
2026-04-01 00:06:10 -07:00
},
2026-03-31 18:47:00 +03:00
resolvePendingDraftWorktreeTarget : ( requestId , directory , options ) =>
set (( s ) => {
if ( ! s . newSessionDraft ? . open || s . newSessionDraft . pendingWorktreeRequestId !== requestId ) return s
return {
newSessionDraft : {
... s . newSessionDraft ,
selectedProjectId : ( options as Record < string , unknown > | undefined ) ? . projectId as string ?? s . newSessionDraft . selectedProjectId ?? null ,
directoryOverride : normalizePath ( directory ),
pendingWorktreeRequestId : null ,
bootstrapPendingDirectory : normalizePath (( options as Record < string , unknown > | undefined ) ? . bootstrapPendingDirectory as string ?? s . newSessionDraft . bootstrapPendingDirectory ?? null ),
preserveDirectoryOverride : (( options as Record < string , unknown > | undefined ) ? . preserveDirectoryOverride ?? true ) as boolean ,
},
}
}),
setDraftBootstrapPendingDirectory : ( directory ) =>
set (( s ) => {
if ( ! s . newSessionDraft ? . open ) return s
return { newSessionDraft : { ... s . newSessionDraft , bootstrapPendingDirectory : normalizePath ( directory ) } }
}),
setPendingDraftWorktreeRequest : ( requestId ) =>
set (( s ) => {
if ( ! s . newSessionDraft ? . open ) return s
return { newSessionDraft : { ... s . newSessionDraft , pendingWorktreeRequestId : requestId } }
}),
getWorktreeMetadata : ( sessionId ) => get (). worktreeMetadata . get ( sessionId ),
2026-04-22 03:34:06 +08:00
dismissPendingChangesBar : ( sessionId , signature ) => {
const map = new Map ( get (). pendingChangesBarDismissed );
if ( signature === null ) {
map . delete ( sessionId );
} else {
map . set ( sessionId , signature );
}
set ({ pendingChangesBarDismissed : map });
},
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
// sendMessage — calls SDK, reads domain data from sync
// ---------------------------------------------------------------------------
2026-07-12 01:23:22 +03:00
// Armed goal (composer target button): the sent prompt becomes the goal
// objective; budget comes from the global default setting. Fire-and-forget —
// a failed metadata patch must not fail the send.
2026-03-31 18:47:00 +03:00
sendMessage : async (
content : string ,
providerID : string ,
modelID : string ,
agent? : string ,
attachments? : AttachedFile [],
agentMentionName? : string ,
additionalParts? : Array < { text : string ; attachments? : AttachedFile []; synthetic? : boolean } > ,
variant? : string ,
inputMode ?: "normal" | "shell" ,
2026-05-25 00:29:52 +03:00
options? : SendMessageOptions ,
2026-03-31 18:47:00 +03:00
) => {
2026-04-22 03:34:06 +08:00
// Clear non-Git changed-files bar on new user message for current session
2026-05-25 00:29:52 +03:00
const sid = options ? . sessionId ?? get (). currentSessionId ;
2026-04-22 03:34:06 +08:00
if ( sid ) {
const map = new Map ( get (). pendingChangesBarDismissed );
map . delete ( sid );
set ({ pendingChangesBarDismissed : map });
}
2026-03-31 18:47:00 +03:00
const draft = get (). newSessionDraft
const trimmedAgent = typeof agent === "string" && agent . trim (). length > 0 ? agent . trim () : undefined
2026-07-12 01:23:22 +03:00
const goalArm = inputMode !== "shell" && content . trim (). length > 0
? useSessionGoalArmStore . getState (). consume ()
: { armed : false , objectiveOverride : null }
const goalArmed = goalArm . armed
if ( goalArmed ) {
// Teach the agent the goal protocol from turn one — without this it
// only learns about goal mode from the first server continuation.
const uiState = useUIStore . getState ()
const budgetLine = uiState . sessionGoalDefaultBudgetEnabled
? ` A token budget of ${ uiState . sessionGoalDefaultBudget } tokens applies to this goal.`
: ""
const goalIntro = wrapSystemReminder (
"Goal mode is active for this session. The user message above defines the goal objective. "
+ "Work toward it across turns; whenever you stop before the objective is verifiably complete, the system will automatically prompt you to continue. "
+ "Progress is evaluated independently after each turn, so end every turn with a clear, factual statement of what is done, what was verified, and what remains."
+ budgetLine ,
)
additionalParts = [...( additionalParts ?? []), { text : goalIntro , synthetic : true }]
}
const applyArmedGoal = ( goalSessionId : string , goalDirectory : string | null | undefined ) => {
if ( ! goalArmed ) return
const uiState = useUIStore . getState ()
const tokenBudget = uiState . sessionGoalDefaultBudgetEnabled ? uiState.sessionGoalDefaultBudget : null
const objective = goalArm . objectiveOverride ? . trim () || content
void setSessionGoal ( goalSessionId , goalDirectory ?? undefined , { objective , tokenBudget }, null )
. catch (( error ) => {
console . warn ( "[session-ui-store] failed to set goal from armed send" , error )
})
}
2026-03-31 18:47:00 +03:00
// ---- New session from draft ----
2026-05-25 00:29:52 +03:00
if ( ! options ? . sessionId && draft ? . open ) {
2026-06-26 19:52:39 +11:00
const createdDraftSession = await materializeOpenDraftSession ({
providerID ,
modelID ,
agent : trimmedAgent ,
variant ,
2026-06-03 14:36:59 +03:00
})
2026-06-26 19:52:39 +11:00
if ( ! createdDraftSession ) throw new Error ( "Failed to create session" )
2026-03-31 18:47:00 +03:00
2026-06-26 19:52:39 +11:00
const mergedAdditionalParts = createdDraftSession . syntheticParts ? . length
? [...( additionalParts || []), ... createdDraftSession . syntheticParts ]
2026-03-31 18:47:00 +03:00
: additionalParts
2026-06-26 19:52:39 +11:00
notifyMessageSent ( createdDraftSession . sessionId )
2026-04-27 05:09:56 -04:00
2026-06-26 19:52:39 +11:00
markPendingUserSendAnimation ( createdDraftSession . sessionId )
2026-03-31 18:47:00 +03:00
const files = attachments ? . map (( a ) => ({
type : "file" as const ,
mime : a.mimeType ,
url : a.dataUrl ,
filename : a.filename ,
}))
await routeMessage ({
2026-06-26 19:52:39 +11:00
sessionId : createdDraftSession.sessionId ,
directory : createdDraftSession.directory ,
2026-03-31 18:47:00 +03:00
content ,
providerID ,
modelID ,
2026-06-26 19:52:39 +11:00
agent : createdDraftSession.agent ,
2026-05-14 15:30:10 +03:00
agentMentionName ,
2026-03-31 18:47:00 +03:00
variant ,
inputMode ,
files ,
2026-06-29 09:28:20 +11:00
delivery : options?.delivery ,
2026-03-31 18:47:00 +03:00
additionalParts : mergedAdditionalParts?.map (( p ) => ({
text : p.text ,
synthetic : p.synthetic ,
files : p.attachments?.map (( a : AttachedFile ) => ({
type : "file" as const ,
mime : a.mimeType ,
url : a.dataUrl ,
filename : a.filename ,
})),
})),
})
2026-07-12 01:23:22 +03:00
applyArmedGoal ( createdDraftSession . sessionId , createdDraftSession . directory )
2026-03-31 18:47:00 +03:00
return
}
// ---- Existing session ----
2026-05-25 00:29:52 +03:00
const targetSessionId = options ? . sessionId ?? get (). currentSessionId
const sessionAgentSelection = targetSessionId
? useSelectionStore . getState (). getSessionAgentSelection ( targetSessionId )
2026-03-31 18:47:00 +03:00
: null
const configAgentName = useConfigStore . getState (). currentAgentName
const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined
2026-06-06 23:22:16 +03:00
if ( targetSessionId ) {
useSelectionStore . getState (). saveSessionModelSelection ( targetSessionId , providerID , modelID )
}
2026-05-25 00:29:52 +03:00
if ( targetSessionId && effectiveAgent ) {
useSelectionStore . getState (). saveSessionAgentSelection ( targetSessionId , effectiveAgent )
2026-06-06 23:22:16 +03:00
useSelectionStore . getState (). saveAgentModelForSession ( targetSessionId , effectiveAgent , providerID , modelID )
2026-05-25 00:29:52 +03:00
useSelectionStore . getState (). saveAgentModelVariantForSession ( targetSessionId , effectiveAgent , providerID , modelID , variant )
2026-03-31 18:47:00 +03:00
}
2026-05-25 00:29:52 +03:00
if ( targetSessionId ) {
2026-03-31 18:47:00 +03:00
const viewportState = useViewportStore . getState ()
2026-06-02 00:43:05 +03:00
const memState = getViewportSessionMemory ( targetSessionId )
2026-03-31 18:47:00 +03:00
if ( ! memState || ! memState . lastUserMessageAt ) {
const newMemState = new Map ( viewportState . sessionMemoryState )
2026-06-02 00:43:05 +03:00
newMemState . set ( viewportSessionKey ( targetSessionId ), {
2026-05-01 17:39:04 +08:00
viewportAnchor : 0 ,
isStreaming : false ,
2026-03-31 18:47:00 +03:00
lastAccessedAt : Date.now (),
2026-05-01 17:39:04 +08:00
backgroundMessageCount : 0 ,
... memState ,
2026-03-31 18:47:00 +03:00
lastUserMessageAt : Date.now (),
})
useViewportStore . setState ({ sessionMemoryState : newMemState })
}
}
2026-05-25 00:29:52 +03:00
const currentSessionDirectory = targetSessionId
2026-07-21 20:52:20 +03:00
? normalizePath ( options ? . directory ?? get (). getDirectoryForSession ( targetSessionId ))
2026-03-31 18:47:00 +03:00
: null
2026-05-25 00:29:52 +03:00
if ( targetSessionId ) {
notifyMessageSent ( targetSessionId )
2026-03-31 18:47:00 +03:00
}
2026-05-25 00:29:52 +03:00
if ( targetSessionId ) {
markPendingUserSendAnimation ( targetSessionId )
2026-03-31 18:47:00 +03:00
}
const files = attachments ? . map (( a ) => ({
type : "file" as const ,
mime : a.mimeType ,
url : a.dataUrl ,
filename : a.filename ,
}))
await routeMessage ({
2026-05-25 00:29:52 +03:00
sessionId : targetSessionId || "" ,
directory : currentSessionDirectory ,
2026-03-31 18:47:00 +03:00
content ,
providerID ,
modelID ,
agent : effectiveAgent ,
2026-05-14 15:30:10 +03:00
agentMentionName ,
2026-03-31 18:47:00 +03:00
variant ,
inputMode ,
files ,
2026-06-29 09:28:20 +11:00
delivery : options?.delivery ,
2026-03-31 18:47:00 +03:00
additionalParts : additionalParts?.map (( p ) => ({
text : p.text ,
synthetic : p.synthetic ,
files : p.attachments?.map (( a ) => ({
type : "file" as const ,
mime : a.mimeType ,
url : a.dataUrl ,
filename : a.filename ,
})),
})),
})
2026-07-12 01:23:22 +03:00
if ( targetSessionId ) {
applyArmedGoal ( targetSessionId , currentSessionDirectory )
}
2026-03-31 18:47:00 +03:00
},
// ---------------------------------------------------------------------------
// createSession
// ---------------------------------------------------------------------------
2026-06-07 01:22:40 +03:00
createSession : async ( title , directoryOverride , parentID , metadata ) => {
2026-03-31 18:47:00 +03:00
const draft = get (). newSessionDraft
const targetFolderId = draft . targetFolderId
try {
const dir = directoryOverride ?? opencodeClient . getDirectory ()
2026-06-07 01:22:40 +03:00
const session = await createSessionAction ( title , dir , parentID ?? null , metadata )
2026-03-31 18:47:00 +03:00
if ( ! session ) return null
2026-07-17 10:31:56 +03:00
get (). closeNewSessionDraft ()
2026-03-31 18:47:00 +03:00
if ( targetFolderId ) {
const scopeKey = directoryOverride || get (). lastLoadedDirectory || session . directory
if ( scopeKey ) {
useSessionFoldersStore . getState (). addSessionToFolder ( scopeKey , targetFolderId , session . id )
}
}
return session
} catch ( e ) {
console . error ( "[session-ui-store] createSession failed" , e )
return null
}
},
// ---------------------------------------------------------------------------
// deleteSession — calls SDK, SSE event updates child store
// ---------------------------------------------------------------------------
deleteSession : ( id ) => deleteSessionAction ( id ),
deleteSessions : async ( ids ) => {
const deletedIds : string [] = []
const failedIds : string [] = []
for ( const id of ids ) {
const ok = await deleteSessionAction ( id )
if ( ok ) deletedIds . push ( id )
else failedIds . push ( id )
}
return { deletedIds , failedIds }
},
archiveSession : ( id ) => archiveSessionAction ( id ),
archiveSessions : async ( ids ) => {
const archivedIds : string [] = []
const failedIds : string [] = []
for ( const id of ids ) {
const ok = await archiveSessionAction ( id )
if ( ok ) archivedIds . push ( id )
else failedIds . push ( id )
}
return { archivedIds , failedIds }
},
// ---------------------------------------------------------------------------
// updateSessionTitle — calls SDK, SSE event updates child store
// ---------------------------------------------------------------------------
updateSessionTitle : async ( sessionId , title ) => {
await updateSessionTitleAction ( sessionId , title )
},
shareSession : async ( sessionId ) => {
return shareSessionAction ( sessionId )
},
unshareSession : async ( sessionId ) => {
return unshareSessionAction ( sessionId )
},
// ---------------------------------------------------------------------------
// revertToMessage — delegates to session-actions (single implementation)
// ---------------------------------------------------------------------------
revertToMessage : async ( sessionId , messageId ) => {
2026-05-16 21:44:37 +08:00
// Ensure the complete message range is present before applying the revert
// marker. Reverted UI is derived from session.revert + stored messages.
await refetchSessionMessages ( sessionId )
2026-06-02 00:43:05 +03:00
await revertToMessageAction ( sessionId , messageId )
2026-03-31 18:47:00 +03:00
},
// ---------------------------------------------------------------------------
2026-05-16 21:44:37 +08:00
// handleSlashUndo — reads from sync, records history for redo
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
handleSlashUndo : async ( sessionId ) => {
const messages = getSyncMessages ( sessionId )
const sessions = getSyncSessions ()
const currentSession = sessions . find (( s ) => s . id === sessionId )
const userMessages = messages . filter (( m ) => m . role === "user" )
if ( userMessages . length === 0 ) return
const revertToId = currentSession ? . revert ? . messageID
let targetMessage : typeof messages [ number ] | undefined
if ( revertToId ) {
2026-05-08 14:53:48 +03:00
targetMessage = [... userMessages ]. reverse (). find (( m ) => m . id < revertToId )
2026-03-31 18:47:00 +03:00
} else {
targetMessage = userMessages [ userMessages . length - 1 ]
}
if ( ! targetMessage ) return
2026-05-16 21:44:37 +08:00
// Read target message parts BEFORE calling revertToMessage.
// revertToMessage optimistically deletes messages from the sync store
// before the API call, so getSyncParts must run first.
2026-03-31 18:47:00 +03:00
const targetParts = getSyncParts ( targetMessage . id )
const textPart = targetParts . find (( p : Part ) => p . type === "text" ) as TextPart | undefined
const preview = textPart ? . text
? String ( textPart . text ). slice ( 0 , 50 ) + ( textPart . text . length > 50 ? "..." : "" )
: "[No text]"
2026-05-16 21:44:37 +08:00
// revertToMessage handles the redo stack push internally
2026-03-31 18:47:00 +03:00
await get (). revertToMessage ( sessionId , targetMessage . id )
const { toast } = await import ( "sonner" )
2026-05-16 21:44:37 +08:00
const { useI18nStore , formatMessage } = await import ( "@/lib/i18n/store" )
const { dictionary } = useI18nStore . getState ()
toast . success ( formatMessage ( dictionary , "chat.revert.toast.undo" , { preview }))
2026-03-31 18:47:00 +03:00
},
// ---------------------------------------------------------------------------
2026-05-16 21:44:37 +08:00
// handleSlashRedo — moves the authoritative revert marker forward
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
2026-05-16 21:44:37 +08:00
handleSlashRedo : async ( sessionId , options ) => {
if ( options ? . fullUnrevert ) {
const { unrevertSession } = await import ( "./session-actions" )
await unrevertSession ( sessionId )
const { toast } = await import ( "sonner" )
const { useI18nStore , formatMessage } = await import ( "@/lib/i18n/store" )
const { dictionary } = useI18nStore . getState ()
toast . success ( formatMessage ( dictionary , "chat.revert.toast.restored" ))
return
}
2026-03-31 18:47:00 +03:00
const sessions = getSyncSessions ()
const currentSession = sessions . find (( s ) => s . id === sessionId )
const revertToId = currentSession ? . revert ? . messageID
if ( ! revertToId ) return
2026-05-08 14:53:48 +03:00
await refetchSessionMessages ( sessionId )
2026-03-31 18:47:00 +03:00
const messages = getSyncMessages ( sessionId )
const userMessages = messages . filter (( m ) => m . role === "user" )
2026-05-08 14:53:48 +03:00
const targetMessage = userMessages . find (( m ) => m . id > revertToId )
2026-03-31 18:47:00 +03:00
if ( targetMessage ) {
2026-05-16 21:44:37 +08:00
await get (). revertToMessage ( sessionId , targetMessage . id , { skipRedoPush : true })
2026-03-31 18:47:00 +03:00
const { toast } = await import ( "sonner" )
2026-05-16 21:44:37 +08:00
const { useI18nStore , formatMessage } = await import ( "@/lib/i18n/store" )
const { dictionary } = useI18nStore . getState ()
toast . success ( formatMessage ( dictionary , "chat.revert.toast.redo" ))
return
2026-03-31 18:47:00 +03:00
}
2026-05-16 21:44:37 +08:00
2026-06-02 00:43:05 +03:00
await unrevertSessionAction ( sessionId )
2026-05-16 21:44:37 +08:00
const { toast } = await import ( "sonner" )
const { useI18nStore , formatMessage } = await import ( "@/lib/i18n/store" )
const { dictionary } = useI18nStore . getState ()
toast . success ( formatMessage ( dictionary , "chat.revert.toast.restored" ))
2026-03-31 18:47:00 +03:00
},
// ---------------------------------------------------------------------------
// forkFromMessage — delegates to session-actions (handles text + sidebar)
// ---------------------------------------------------------------------------
forkFromMessage : async ( sessionId , messageId ) => {
const sessions = getSyncSessions ()
const existingSession = sessions . find (( s ) => s . id === sessionId )
if ( ! existingSession ) return
try {
2026-06-02 00:43:05 +03:00
await forkFromMessageAction ( sessionId , messageId )
2026-03-31 18:47:00 +03:00
const { toast } = await import ( "sonner" )
toast . success ( `Forked from ${ existingSession . title } ` )
} catch ( error ) {
console . error ( "Failed to fork session:" , error )
const { toast } = await import ( "sonner" )
toast . error ( "Failed to fork session" )
}
},
// ---------------------------------------------------------------------------
// createSessionFromAssistantMessage — reads from sync
// ---------------------------------------------------------------------------
2026-06-02 12:53:30 +03:00
createSessionFromAssistantMessage : async ( sourceMessageId , execution ) => {
2026-03-31 18:47:00 +03:00
if ( ! sourceMessageId ) return
2026-06-02 12:53:30 +03:00
if ( ! execution ? . instructions ? . trim ()) return
2026-03-31 18:47:00 +03:00
// Find which session this message belongs to by scanning sync state
const state = getDirectoryState ()
if ( ! state ) return
let sourceSessionId : string | undefined
let sourceMessage : Message | undefined
for ( const [ sid , msgs ] of Object . entries ( state . message ?? {})) {
const found = msgs . find (( m ) => m . id === sourceMessageId )
if ( found ) {
sourceSessionId = sid
sourceMessage = found
break
}
}
if ( ! sourceMessage || sourceMessage . role !== "assistant" ) return
const sourceParts = getSyncParts ( sourceMessageId )
const assistantPlanText = flattenAssistantTextParts ( sourceParts )
if ( ! assistantPlanText . trim ()) return
const directory = resolveSessionDirectory (
sourceSessionId ?? null ,
( sid ) => get (). worktreeMetadata . get ( sid ),
)
2026-06-06 23:22:16 +03:00
const sourceWorktreeMetadata = sourceSessionId ? get (). worktreeMetadata . get ( sourceSessionId ) : undefined
2026-03-31 18:47:00 +03:00
2026-06-02 12:53:30 +03:00
const pID = execution . providerID || useSelectionStore . getState (). lastUsedProvider ? . providerID
const mID = execution . modelID || useSelectionStore . getState (). lastUsedProvider ? . modelID
2026-03-31 18:47:00 +03:00
if ( ! pID || ! mID ) return
2026-06-06 23:22:16 +03:00
const sourceDirectory = normalizePath ( directory ?? opencodeClient . getDirectory () ?? null )
let sessionDirectory = sourceDirectory
let createdWorktree : WorktreeMetadata | null = null
let createdWorktreeProject : { id : string ; path : string } | null = null
if ( execution . createWorktree ) {
const projects = useProjectsStore . getState (). projects
const project = resolveProjectForSessionDirectory (
projects ,
get (). availableWorktreesByProject ,
sourceDirectory ,
) ?? resolveProjectForSessionDirectory (
projects ,
get (). availableWorktreesByProject ,
sourceWorktreeMetadata ? . projectDirectory ?? null ,
)
if ( ! project ? . path ) {
throw new Error ( "Project is not registered in OpenChamber" )
}
const [ branchNameModule , configModule , createModule ] = await Promise . all ([
import ( "@/lib/git/branchNameGenerator" ),
import ( "@/lib/openchamberConfig" ),
import ( "@/lib/worktrees/worktreeCreate" ),
])
const branchName = branchNameModule . generateBranchName ()
createdWorktreeProject = { id : project.id , path : project.path }
const setupCommands = await configModule . getWorktreeSetupCommands ( createdWorktreeProject )
createdWorktree = await createModule . createWorktreeWithDefaults ( createdWorktreeProject , {
preferredName : branchName ,
mode : "new" ,
branchName ,
worktreeName : branchName ,
setupCommands ,
returnAfterDirectoryCreated : true ,
})
sessionDirectory = normalizePath ( createdWorktree . path )
2026-06-26 20:16:42 +11:00
if ( ! sessionDirectory ) {
throw new Error ( "Worktree create missing name/path" )
}
if ( await configModule . getWorktreeSetupWaitEnabled ( createdWorktreeProject )) {
await waitForWorktreeBootstrap ( sessionDirectory )
}
2026-06-06 23:22:16 +03:00
}
const session = await get (). createSession ( undefined , sessionDirectory || null , null )
if ( ! session ) {
if ( createdWorktree && createdWorktreeProject ) {
const { removeProjectWorktree } = await import ( "@/lib/worktrees/worktreeManager" )
await removeProjectWorktree ( createdWorktreeProject , createdWorktree , { deleteLocalBranch : true }). catch (() => undefined )
}
return
}
if ( createdWorktree ) {
get (). setWorktreeMetadata ( session . id , {
... createdWorktree ,
kind : "standard" ,
})
useDirectoryStore . getState (). setDirectory ( createdWorktree . path , { showOverlay : false })
}
2026-07-12 01:23:22 +03:00
// "Run as goal" rides the same arm mechanism as the composer target
// button: sendMessage consumes the flag, stamps the goal (objective =
// the composed fork message) and attaches the goal-mode intro part.
// Set explicitly either way so a stray armed flag cannot leak into a
// non-goal fork.
useSessionGoalArmStore . getState (). setArmed ( execution . runAsGoal === true )
2026-06-06 23:22:16 +03:00
await get (). sendMessage (
composeForkSessionMessage ( execution . instructions , assistantPlanText ),
pID ,
mID ,
execution . agent || undefined ,
undefined ,
undefined ,
undefined ,
execution . variant || undefined ,
undefined ,
{ sessionId : session.id },
)
2026-03-31 18:47:00 +03:00
},
// ---------------------------------------------------------------------------
// Data access helpers — read from sync
// ---------------------------------------------------------------------------
getSessionsByDirectory : ( directory ) => {
const nd = normalizePath ( directory )
if ( ! nd ) return []
const sessions = getAllSyncSessions ()
return sessions . filter (( s ) => resolveDirectoryKey ( s ) === nd )
},
getDirectoryForSession : ( sessionId ) => {
2026-06-03 22:38:15 +03:00
if ( sessionId === get (). currentSessionId && get (). currentSessionDirectory ) {
return get (). currentSessionDirectory
}
const resolved = resolveSessionDirectory ( sessionId , ( sid ) => get (). worktreeMetadata . get ( sid ))
if ( resolved ) return resolved
2026-04-17 01:13:59 +08:00
const attachmentDirectory = getAttachedSessionDirectory ( getAttachmentForSession ( sessionId ))
if ( attachmentDirectory ) return attachmentDirectory
2026-03-31 18:47:00 +03:00
const sessions = getAllSyncSessions ()
const session = sessions . find (( s ) => s . id === sessionId )
2026-05-13 03:12:30 -05:00
if ( session ) return resolveDirectoryKey ( session )
const globalStore = useGlobalSessionsStore . getState ()
const globalSession = [... globalStore . activeSessions , ... globalStore . archivedSessions ]
. find (( s ) => s . id === sessionId )
if ( globalSession ) return resolveGlobalSessionDirectory ( globalSession )
return null
2026-03-31 18:47:00 +03:00
},
getLastUserChoice : ( sessionId ) => {
const directory = get (). getDirectoryForSession ( sessionId ) ?? undefined
const messages = getSyncMessages ( sessionId , directory )
for ( let i = messages . length - 1 ; i >= 0 ; i -= 1 ) {
const message = messages [ i ] as Message & {
2026-04-12 00:40:49 +03:00
model ?: { providerID? : string ; modelID? : string ; variant? : string }
2026-03-31 18:47:00 +03:00
variant? : string
mode? : string
}
if ( message . role !== "user" ) {
continue
}
const providerID = typeof message . model ? . providerID === "string" && message . model . providerID . trim (). length > 0
? message.model.providerID
: undefined
const modelID = typeof message . model ? . modelID === "string" && message . model . modelID . trim (). length > 0
? message.model.modelID
: undefined
const agent = typeof message . agent === "string" && message . agent . trim (). length > 0
? message . agent
: ( typeof message . mode === "string" && message . mode . trim (). length > 0 ? message.mode : undefined )
2026-04-12 00:40:49 +03:00
const variantCandidate = message . model ? . variant ?? message . variant
const variant = typeof variantCandidate === "string" && variantCandidate . trim (). length > 0
? variantCandidate
2026-03-31 18:47:00 +03:00
: undefined
return { agent , providerID , modelID , variant }
}
return null
},
getCurrentAgent : ( sessionId ) => {
return useSelectionStore . getState (). sessionAgentSelections . get ( sessionId ) ?? undefined
},
debugSessionMessages : async ( sessionId ) => {
const msgs = getSyncMessages ( sessionId )
const sessions = getSyncSessions ()
const session = sessions . find (( s ) => s . id === sessionId )
console . log ( `Debug session ${ sessionId } :` , {
session ,
messageCount : msgs.length ,
messages : msgs.map (( m ) => ({
id : m.id ,
role : m.role ,
tokens : m.role === "assistant" ? m.tokens : undefined ,
})),
})
},
pollForTokenUpdates : () => {
// Handled by sync system's SSE stream
},
2026-06-03 22:38:15 +03:00
setSessionDirectory : ( sessionId , directory ) => {
const normalized = normalizePath ( directory )
if ( sessionId === get (). currentSessionId ) {
set ({ currentSessionDirectory : normalized })
writeRuntimeSessionMemory ( runtimeMemoryKey (), { sessionId , directory : normalized })
}
2026-03-31 18:47:00 +03:00
},
2026-04-06 20:44:13 +03:00
// ---------------------------------------------------------------------------
// Plan mode availability tracking
// ---------------------------------------------------------------------------
markSessionPlanAvailable : ( sessionId ) => {
set (( state ) => {
2026-05-21 15:45:15 +03:00
if ( state . sessionPlanAvailable . get ( sessionId ) === true ) {
return state
}
2026-04-06 20:44:13 +03:00
const next = new Map ( state . sessionPlanAvailable )
next . set ( sessionId , true )
return { sessionPlanAvailable : next }
})
},
isSessionPlanAvailable : ( sessionId ) => {
return get (). sessionPlanAvailable . get ( sessionId ) ?? false
},
2026-03-31 18:47:00 +03:00
}))
2026-06-02 00:43:05 +03:00
setSessionOpener (( sessionID , directory ) => {
useSessionUIStore . getState (). setCurrentSession ( sessionID , directory )
})
2026-06-15 03:16:34 +03:00
// Write-through persist of the worktree map whenever discovery refreshes it.
2026-07-12 00:06:58 +11:00
// Reference-equality guard filters hot session updates; the serialized
// comparison avoids redundant localStorage writes when the Map reference
// changed but the content is identical (e.g., re-discovery that found the
// same worktrees).
2026-07-21 20:52:20 +03:00
const lastPersistedWorktreeSerializedByRuntime = new Map < string , string >()
2026-06-15 03:16:34 +03:00
useSessionUIStore . subscribe (( state , prev ) => {
if ( state . availableWorktreesByProject !== prev . availableWorktreesByProject ) {
2026-07-21 20:52:20 +03:00
const runtimeKey = runtimeMemoryKey ()
2026-07-12 00:06:58 +11:00
const serialized = JSON . stringify ([... state . availableWorktreesByProject . entries ()])
2026-07-21 20:52:20 +03:00
if ( serialized !== lastPersistedWorktreeSerializedByRuntime . get ( runtimeKey )) {
lastPersistedWorktreeSerializedByRuntime . set ( runtimeKey , serialized )
persistWorktreeTopology ( runtimeKey , state . availableWorktreesByProject )
2026-07-12 00:06:58 +11:00
}
2026-06-15 03:16:34 +03:00
}
})