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-08-18 02:59:04 +03:00
import { fetchSessionKnowledge , reportSessionKnowledgeDelivered } from "@/lib/sessionKnowledgeApi"
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-08-21 12:12:40 +03:00
import { CHAT_DRAFT_PROJECT_ID , createChatDirectory , deleteChatDirectory , warmChatsRootDirectory } from "@/lib/chatDirectories"
import { isVSCodeRuntime } from "@/lib/desktop"
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-08-04 12:46:48 +00:00
import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice"
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 ,
2026-08-03 12:50:48 +03:00
getSyncSessionDirectory ,
2026-03-31 18:47:00 +03:00
} from "./sync-refs"
2026-08-03 12:50:48 +03:00
import {
resolveSessionDirectoryFromSources ,
type SessionDirectoryResolution ,
type SessionDirectorySources ,
} from "./session-directory-resolution"
2026-03-31 18:47:00 +03:00
import { markSessionViewed } from "./notification-store"
import { setActiveSession } from "./sync-context"
import {
createSession as createSessionAction ,
deleteSession as deleteSessionAction ,
2026-08-02 15:13:06 +00:00
deleteSessions as deleteSessionsAction ,
2026-03-31 18:47:00 +03:00
archiveSession as archiveSessionAction ,
2026-08-02 11:55:02 +00:00
archiveSessions as archiveSessionsAction ,
2026-08-04 13:21:04 +03:00
unarchiveSession as unarchiveSessionAction ,
unarchiveSessions as unarchiveSessionsAction ,
2026-03-31 18:47:00 +03:00
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-08-02 11:55:02 +00:00
type ArchiveSessionsOptions ,
2026-08-02 15:13:06 +00:00
type DeleteSessionOptions ,
type DeleteSessionsOptions ,
2026-08-04 13:21:04 +03:00
type UnarchiveSessionsOptions ,
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-08-03 12:50:48 +03:00
import { clearLastActiveSession , persistLastActiveSession , readLastActiveSession } from "./last-session-cache"
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-08-14 23:06:56 +02:00
import { contextTokensFromBreakdown } from "@/stores/utils/tokenUtils"
2026-03-31 18:47:00 +03:00
export type { AttachedFile }
2026-07-29 00:47:29 +03:00
type GoalCommand = { name : string ; template? : string }
export function expandSlashCommandGoalObjective ( content : string , commands : GoalCommand []) : string {
if ( ! content . startsWith ( "/" )) return content
const [ head , ... tail ] = content . split ( " " )
const command = commands . find (( candidate ) => candidate . name === head . slice ( 1 ))
if ( ! command ? . template ? . trim ()) return content
const argumentsText = tail . join ( " " )
if ( command . template . includes ( "$ARGUMENTS" )) {
return command . template . replaceAll ( "$ARGUMENTS" , argumentsText )
}
const positions = [... command . template . matchAll ( /\$(\d+)/g )]. map (( match ) => Number ( match [ 1 ]))
if ( positions . length > 0 ) {
const parsedArguments = [... argumentsText . matchAll ( /"([^"]*)"|'([^']*)'|(\S+)/g )]
. map (( match ) => match [ 1 ] ?? match [ 2 ] ?? match [ 3 ] ?? "" )
const lastPosition = Math . max (... positions )
return command . template . replace ( /\$(\d+)/g , ( _match , value : string ) => {
const position = Number ( value )
return position === lastPosition
? parsedArguments . slice ( position - 1 ). join ( " " )
: ( parsedArguments [ position - 1 ] ?? "" )
})
}
return argumentsText ? ` ${ command . template } \ n \ n ${ argumentsText } ` : command . template
}
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
// Send routing — shell mode, slash commands, or normal prompt
// ---------------------------------------------------------------------------
2026-05-25 16:00:48 +03:00
export function routeMessage ( params : {
2026-08-07 05:46:21 +08:00
runtimeKey? : string
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 ({
2026-08-07 05:46:21 +08:00
runtimeKey : params.runtimeKey ,
2026-06-02 00:43:05 +03:00
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 ({
2026-08-07 05:46:21 +08:00
runtimeKey : params.runtimeKey ,
2026-06-02 00:43:05 +03:00
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 ({
2026-08-07 05:46:21 +08:00
runtimeKey : params.runtimeKey ,
2026-06-02 00:43:05 +03:00
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 ({
2026-08-07 05:46:21 +08:00
runtimeKey : params.runtimeKey ,
2026-06-02 00:43:05 +03:00
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 ({
2026-08-07 05:46:21 +08:00
runtimeKey : params.runtimeKey ,
2026-06-02 00:43:05 +03:00
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
}
2026-08-07 05:46:21 +08:00
type CapturedSendTarget = {
runtimeKey : string
sessionId : string
directory : string
}
2026-05-25 00:29:52 +03:00
type SendMessageOptions = {
2026-08-07 05:46:21 +08:00
target? : CapturedSendTarget
2026-05-25 00:29:52 +03:00
sessionId? : string
2026-07-21 20:52:20 +03:00
directory? : string
2026-08-13 12:31:33 +03:00
/** Immutable copy of the new-session draft at submit time; used instead of the live draft. */
draftSnapshot? : NewSessionDraftState
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 NewSessionDraftState = {
2026-08-21 12:12:40 +03:00
draftId : number
2026-03-31 18:47:00 +03:00
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
2026-08-19 00:07:57 +03:00
projectContextPins ?: { notes : string []; plans : string [] }
2026-08-21 12:12:40 +03:00
target : "chat" | "project"
preparedChatDirectory? : string | null
2026-03-31 18:47:00 +03:00
}
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-08-01 21:16:36 +03:00
openNewSessionDraft : ( options? : Partial < NewSessionDraftState > & { automatic? : boolean }) => void
2026-08-21 12:12:40 +03:00
prepareChatDraftDirectory : () => Promise < string | null >
2026-03-31 18:47:00 +03:00
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-08-19 00:07:57 +03:00
setDraftProjectContextPin : ( kind : "note" | "plan" , id : string , pinned : 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-08-02 15:13:06 +00:00
deleteSession : ( id : string , options? : DeleteSessionOptions ) => Promise < boolean >
deleteSessions : ( ids : string [], options? : DeleteSessionsOptions ) => Promise < { deletedIds : string []; failedIds : string [] } >
2026-03-31 18:47:00 +03:00
archiveSession : ( id : string ) => Promise < boolean >
2026-08-02 11:55:02 +00:00
archiveSessions : ( ids : string [], options? : ArchiveSessionsOptions ) => Promise < { archivedIds : string []; failedIds : string [] } >
2026-08-04 13:21:04 +03:00
unarchiveSession : ( id : string ) => Promise < boolean >
unarchiveSessions : ( ids : string [], options? : UnarchiveSessionsOptions ) => Promise < { restoredIds : string []; failedIds : string [] } >
2026-03-31 18:47:00 +03:00
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
2026-08-04 00:42:34 +03:00
/**
* Replace a guessed selection directory with the authoritative one once sync
* has indexed the session. Safe to call at any time: it only ever promotes a
* guess, never overrides a confirmed selection.
*/
adoptAuthoritativeSessionDirectory : ( sessionId? : string ) => void
2026-03-31 18:47:00 +03:00
}
// ---------------------------------------------------------------------------
// 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-08-04 01:27:03 +03:00
/**
* The directory that owns a session, from the two server-backed signals.
*
2026-08-04 01:50:14 +03:00
* `null` means "not indexed yet", never "no directory" — callers must fall back
* rather than treat it as empty.
*
2026-08-04 01:27:03 +03:00
* The session's own record wins. Holding a session in a child store proves
* containment, not ownership: a project's session list legitimately includes
* the sessions of its worktrees so the sidebar can group them, so the parent
* repository holds worktree sessions too. Reading ownership from store
* membership therefore reports the parent for a session that lives in a
* worktree, and every fetch is then addressed to a directory that does not own
* it. Store membership remains the fallback for a session whose record carries
* no directory.
*/
2026-08-03 12:50:48 +03:00
const getAuthoritativeSessionDirectory = ( sessionId : string ) : string | null => {
const target = getAllSyncSessions (). find (( s ) => s . id === sessionId )
2026-08-04 01:27:03 +03:00
const recordDirectory = target ? resolveDirectoryKey ( target ) : null
if ( recordDirectory ) return normalizePath ( recordDirectory )
const owningDirectory = getSyncSessionDirectory ( sessionId )
return owningDirectory ? normalizePath ( owningDirectory ) : null
2026-08-03 12:50:48 +03:00
}
/**
* Directory remembered for a session in this runtime, plus the one persisted
* across restarts. Exported for diagnostics: a stale persisted directory is the
* hardest source to observe and the one that survives reloads, so a report that
* cannot show it cannot rule it out.
*/
export const getRememberedSessionDirectory = ( sessionId : string ) : {
runtime : string | null
persisted : string | null
} => {
const key = runtimeMemoryKey ()
const runtimeMemory = runtimeSessionMemory . get ( key )
const persisted = readLastActiveSession ( key )
return {
runtime : runtimeMemory?.sessionId === sessionId ? normalizePath ( runtimeMemory . directory ) : null ,
persisted : persisted?.sessionId === sessionId ? normalizePath ( persisted . directory ) : null ,
}
}
/**
* Session whose `currentSessionDirectory` is only the active directory, used
* because the session's own directory was not known at selection time. Such a
* value must never outrank a worktree assignment or reach persistence — it is
* a guess, not a selection.
*/
let guessedSelectionSessionId : string | null = null
const collectSessionDirectorySources = (
sessionId : string ,
getWtMeta : ( id : string ) => WorktreeMetadata | undefined ,
selected : string | null ,
) : SessionDirectorySources => ({
authoritative : getAuthoritativeSessionDirectory ( sessionId ),
selected : sessionId === guessedSelectionSessionId ? null : normalizePath ( selected ),
attachment : getAttachedSessionDirectory ( getAttachmentForSession ( sessionId )),
worktreeMetadata : normalizePath ( getWtMeta ( sessionId ) ? . path ?? null ),
remembered : getRememberedSessionDirectory ( sessionId ). runtime ,
})
/**
* Conflicts already warned about, so a stale directory logs once instead of on
* every keystroke. Keyed by runtime *and* the exact pair of directories: the
* same session ID means a different thing in another runtime, and a conflict
* that reappears after being resolved is news worth logging again. Bounded so
* a long-lived session cannot grow it without limit.
*/
const reportedDirectoryConflicts = new Set < string >()
const MAX_REPORTED_DIRECTORY_CONFLICTS = 200
const reportSessionDirectoryConflict = (
sessionId : string ,
resolution : SessionDirectoryResolution ,
) : void => {
if ( ! resolution . conflict ) return
const conflictKey = JSON . stringify ([
runtimeMemoryKey (),
sessionId ,
resolution . directory ,
resolution . conflict . source ,
resolution . conflict . directory ,
])
if ( reportedDirectoryConflicts . has ( conflictKey )) return
if ( reportedDirectoryConflicts . size >= MAX_REPORTED_DIRECTORY_CONFLICTS ) {
reportedDirectoryConflicts . clear ()
}
reportedDirectoryConflicts . add ( conflictKey )
console . warn (
"[session-directory] session directory sources disagree; using the higher-authority one. "
+ "Run __opencodeDebug.diagnoseSessionDirectory() for the full picture." ,
{
sessionId ,
using : resolution . source ,
directory : resolution.directory ,
conflictingSource : resolution.conflict.source ,
conflictingDirectory : resolution.conflict.directory ,
},
)
}
2026-03-31 18:47:00 +03:00
const resolveSessionDirectory = (
sessionId : string | null | undefined ,
getWtMeta : ( id : string ) => WorktreeMetadata | undefined ,
2026-08-03 12:50:48 +03:00
selected : string | null = null ,
2026-03-31 18:47:00 +03:00
) : string | null => {
if ( ! sessionId ) return null
2026-08-03 12:50:48 +03:00
const resolution = resolveSessionDirectoryFromSources (
collectSessionDirectorySources ( sessionId , getWtMeta , selected ),
)
reportSessionDirectoryConflict ( sessionId , resolution )
return resolution . directory
2026-03-31 18:47:00 +03:00
}
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 = {
2026-08-21 12:12:40 +03:00
draftId : 0 ,
2026-03-31 18:47:00 +03:00
open : false ,
directoryOverride : null ,
parentID : null ,
2026-08-21 12:12:40 +03:00
target : "chat" ,
2026-03-31 18:47:00 +03:00
}
2026-08-21 12:12:40 +03:00
let nextDraftId = 1
const pendingChatDirectoryByDraft = new Map < string , Promise < string | null >>()
2026-03-31 18:47:00 +03:00
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-08-15 04:26:39 +00:00
const resolveActiveProjectDirectory = ( draft : NewSessionDraftState ) : string | null => {
const projectsState = useProjectsStore . getState ()
return normalizePath (
projectsState . getActiveProject () ? . path
?? ( draft . selectedProjectId
? projectsState . projects . find (( project ) => project . id === draft . selectedProjectId ) ? . path
: null )
?? null ,
)
}
/**
* Regular new-chat drafts inherit the persisted current/last directory. If that
* path is confirmed missing (deleted worktree), fall back to the active project.
* Explicit worktree targets, in-flight worktree creation, and unknown/offline
* probes stay unchanged so a temporary outage cannot rewrite the destination.
2026-08-15 06:33:28 +00:00
* A concurrent rewrite of the same implicit draft to that fallback is accepted
* instead of aborting create.
2026-08-15 04:26:39 +00:00
*/
const resolveCreatableDraftDirectory = async (
draft : NewSessionDraftState ,
requestedDirectory : string | null | undefined ,
) : Promise < { status : "ok" ; directory : string | null | undefined } | { status : "aborted" } > => {
const directory = requestedDirectory ?? opencodeClient . getDirectory () ?? null
const isRecoverableDraftDirectory =
draft . open
&& draft . preserveDirectoryOverride !== true
&& ! draft . pendingWorktreeRequestId
&& ! draft . bootstrapPendingDirectory
&& normalizePath ( draft . directoryOverride ) === normalizePath ( directory )
if ( ! isRecoverableDraftDirectory || ! directory ) {
return { status : "ok" , directory }
}
const activeProjectDirectory = resolveActiveProjectDirectory ( draft )
if ( ! activeProjectDirectory || normalizePath ( directory ) === activeProjectDirectory ) {
return { status : "ok" , directory }
}
const runtimeKey = getRuntimeKey ()
const draftDirectory = draft . directoryOverride
const availability = await opencodeClient . getDirectoryAvailability ( directory )
const currentDraft = useSessionUIStore . getState (). newSessionDraft
2026-08-15 06:33:28 +00:00
const currentDirectory = normalizePath ( currentDraft . directoryOverride )
const capturedDirectory = normalizePath ( draftDirectory )
// openNewSessionDraft may rewrite the same implicit draft to this fallback
// while createSession's probe is still in flight. That is the intended
// destination, not a user change, so do not abort the create.
const recoveredToActiveProject = currentDirectory === activeProjectDirectory
&& capturedDirectory !== activeProjectDirectory
2026-08-15 04:26:39 +00:00
const draftChanged = ! currentDraft . open
|| currentDraft . preserveDirectoryOverride !== draft . preserveDirectoryOverride
|| currentDraft . pendingWorktreeRequestId !== draft . pendingWorktreeRequestId
2026-08-15 06:33:28 +00:00
|| ( currentDirectory !== capturedDirectory && ! recoveredToActiveProject )
2026-08-15 04:26:39 +00:00
if ( getRuntimeKey () !== runtimeKey || draftChanged ) {
return { status : "aborted" }
}
2026-08-15 06:33:28 +00:00
if ( recoveredToActiveProject ) {
return { status : "ok" , directory : activeProjectDirectory }
}
2026-08-15 04:26:39 +00:00
return {
status : "ok" ,
directory : availability === "missing" ? activeProjectDirectory : directory ,
}
}
2026-08-15 04:28:07 +00:00
const recoverStaleDraftDirectory = async ( openedDraft : NewSessionDraftState ) : Promise < void > => {
const resolved = await resolveCreatableDraftDirectory ( openedDraft , openedDraft . directoryOverride )
if ( resolved . status !== "ok" ) return
const recovered = normalizePath ( resolved . directory ?? null )
const original = normalizePath ( openedDraft . directoryOverride )
if ( ! recovered || recovered === original ) return
const currentDraft = useSessionUIStore . getState (). newSessionDraft
if ( ! currentDraft . open ) return
if ( currentDraft . preserveDirectoryOverride === true ) return
if ( currentDraft . pendingWorktreeRequestId ) return
if ( normalizePath ( currentDraft . directoryOverride ) !== original ) return
const recoveredProject = useProjectsStore . getState (). projects . find (( project ) => (
normalizePath ( project . path ) === recovered
))
const nextDraft : NewSessionDraftState = {
... currentDraft ,
selectedProjectId : recoveredProject?.id ?? currentDraft . selectedProjectId ,
directoryOverride : recovered ,
}
useSessionUIStore . setState ({ newSessionDraft : nextDraft })
writeRuntimeSessionMemory ( runtimeMemoryKey (), { draft : nextDraft })
persistDraftTarget ({ projectId : nextDraft.selectedProjectId ?? null , directory : recovered })
void activateConfigForDirectory ( recovered )
}
2026-06-26 19:52:39 +11:00
export async function materializeOpenDraftSession ( selection : {
providerID : string
modelID : string
agent? : string
variant? : string
2026-08-13 12:31:33 +03:00
}, draftOverride? : NewSessionDraftState ) : Promise < MaterializedDraftSession | null > {
2026-06-26 19:52:39 +11:00
const store = useSessionUIStore . getState ()
2026-08-13 12:31:33 +03:00
const draft = draftOverride ?? store . newSessionDraft
2026-06-26 19:52:39 +11:00
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-08-21 12:12:40 +03:00
const isChatDraft = draft . target === "chat"
if ( isChatDraft ) {
draftDirectoryOverride = await store . prepareChatDraftDirectory ()
if ( ! draftDirectoryOverride ) throw new Error ( "Failed to prepare chat directory" )
const currentDraft = useSessionUIStore . getState (). newSessionDraft
if ( currentDraft . draftId === draft . draftId ) {
useSessionUIStore . setState ({
newSessionDraft : { ... currentDraft , preparedChatDirectory : null },
})
}
}
2026-06-26 20:16:42 +11:00
await waitForWorktreeBootstrapIfConfigured ( draftDirectoryOverride , draftProjectId )
2026-06-26 19:52:39 +11:00
2026-08-19 00:07:57 +03:00
const draftPins = draft . projectContextPins ?? { notes : [], plans : [] }
const created = await store . createSession (
draft . title ,
draftDirectoryOverride ,
draft . parentID ?? null ,
draftPins . notes . length > 0 || draftPins . plans . length > 0
? { openchamber : { project_context_pins : draftPins } }
: undefined ,
)
2026-08-21 12:12:40 +03:00
if ( ! created ? . id ) {
if ( isChatDraft && draftDirectoryOverride ) {
await deleteChatDirectory ( draftDirectoryOverride ). catch (() => undefined )
}
throw new Error ( "Failed to create session" )
}
2026-06-26 19:52:39 +11:00
2026-08-03 12:50:48 +03:00
// The server response is authoritative. It may canonicalize a requested
// worktree path (for example through a symlink or platform path casing).
// Sending with the pre-canonical draft path can target a different
// directory scope than the session that was just created.
const createdDirectory = normalizePath ( created . directory ?? draftDirectoryOverride ?? null )
2026-06-26 19:52:39 +11:00
persistDraftTarget ({
projectId : draftProjectId ,
2026-08-03 12:50:48 +03:00
directory : createdDirectory ,
2026-06-26 19:52:39 +11:00
})
const draftSyntheticParts = draft . syntheticParts
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
2026-08-03 12:50:48 +03:00
const knownDir = ( directoryHint ? normalizePath ( directoryHint ) : null ) ?? sessionDir
const resolvedDir = knownDir ?? fallbackDir
// `fallbackDir` is the active directory, not this session's directory. It
// keeps routing usable while the owning directory store bootstraps, but it
// must never be remembered: a persisted guess outlives the race that
// produced it and survives reloads and restarts.
const isGuessedDir = knownDir === null
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-08-03 12:50:48 +03:00
guessedSelectionSessionId = isGuessedDir && id ? id : null
const rememberedDir = isGuessedDir ? null : resolvedDir ?? null
writeRuntimeSessionMemory ( key , { sessionId : id , directory : rememberedDir })
2026-08-01 21:16:36 +03:00
// Keep the last NON-null session per runtime across app restarts (cold
// mobile launches reopen it after the instance reconnects). Going back to
// a draft intentionally does not erase it.
if ( id ) {
2026-08-03 12:50:48 +03:00
persistLastActiveSession ( key , { sessionId : id , directory : rememberedDir })
2026-08-01 21:16:36 +03:00
}
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 ) => {
2026-08-01 21:16:36 +03:00
// A USER-initiated draft open is a navigation choice: the next cold launch
// should land on the draft, not re-open the session left behind — drop the
// persisted last-session pointer for this runtime. `automatic: true` marks
// programmatic fallback opens (e.g. ChatContainer's "no session active"
// auto-draft at boot), which must NOT consume the pointer — the cold-launch
// restore races exactly that auto-open.
if ( ! options ? . automatic ) {
clearLastActiveSession ( runtimeMemoryKey ())
}
2026-03-31 18:47:00 +03:00
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
2026-08-21 12:12:40 +03:00
let target = isVSCodeRuntime () ? "project" : options ? . target
if ( ! target ) {
const hasExplicitProjectTarget = options ? . directoryOverride !== undefined
|| ( options ? . selectedProjectId !== undefined && options . selectedProjectId !== CHAT_DRAFT_PROJECT_ID )
|| isVSCodeRuntime ()
target = options ? . selectedProjectId === CHAT_DRAFT_PROJECT_ID || ! hasExplicitProjectTarget
? "chat"
: "project"
}
const explicitProject = target === "project" && options ? . selectedProjectId
2026-03-31 18:47:00 +03:00
? 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 )
2026-08-21 12:12:40 +03:00
const selectedProject = target === "chat" ? null : (() => {
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
})()
2026-08-21 12:12:40 +03:00
const directory = target === "chat" ? null : (() => {
2026-03-31 18:47:00 +03:00
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 )
})()
2026-08-21 12:12:40 +03:00
if ( target === "chat" ) {
warmChatsRootDirectory ()
}
2026-03-31 18:47:00 +03:00
persistDraftTarget ({ projectId : selectedProject?.id ?? null , directory })
2026-06-02 00:43:05 +03:00
const nextDraft : NewSessionDraftState = {
2026-08-21 12:12:40 +03:00
draftId : nextDraftId ++ ,
2026-06-02 00:43:05 +03:00
open : true ,
2026-08-21 12:12:40 +03:00
target ,
preparedChatDirectory : null ,
2026-06-02 00:43:05 +03:00
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-08-19 00:07:57 +03:00
projectContextPins : options?.projectContextPins ,
2026-06-02 00:43:05 +03:00
}
2026-03-31 18:47:00 +03:00
set ({
2026-08-21 12:12:40 +03:00
newSessionDraft : 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-08-15 04:28:07 +00:00
void recoverStaleDraftDirectory ( nextDraft )
2026-03-31 18:47:00 +03:00
},
2026-08-21 12:12:40 +03:00
prepareChatDraftDirectory : async () => {
const draft = get (). newSessionDraft
if ( ! draft . open || draft . target !== "chat" ) return null
if ( draft . preparedChatDirectory ) return draft . preparedChatDirectory
const runtimeKey = getRuntimeKey ()
const key = ` ${ runtimeKey } : ${ draft . draftId } `
const existing = pendingChatDirectoryByDraft . get ( key )
if ( existing ) return existing
const pending = createChatDirectory (). then ( async ( directory ) => {
const current = get (). newSessionDraft
if (
getRuntimeKey () !== runtimeKey
|| ! current . open
|| current . target !== "chat"
|| current . draftId !== draft . draftId
) {
await deleteChatDirectory ( directory ). catch (() => undefined )
return null
}
set ({ newSessionDraft : { ... current , preparedChatDirectory : directory } })
return directory
}). finally (() => {
pendingChatDirectoryByDraft . delete ( key )
})
pendingChatDirectoryByDraft . set ( key , pending )
return pending
},
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
// closeNewSessionDraft
// ---------------------------------------------------------------------------
closeNewSessionDraft : () => {
2026-07-21 20:52:20 +03:00
const currentDraft = get (). newSessionDraft
2026-08-21 12:12:40 +03:00
if ( currentDraft . preparedChatDirectory ) {
void deleteChatDirectory ( currentDraft . preparedChatDirectory ). catch (() => undefined )
}
2026-07-21 20:52:20 +03:00
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-08-21 12:12:40 +03:00
draftId : currentDraft.draftId ,
open : false ,
target : "chat" ,
preparedChatDirectory : null ,
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 ) => {
2026-08-21 12:12:40 +03:00
if ( isVSCodeRuntime () && target . projectId === CHAT_DRAFT_PROJECT_ID ) return
const previousDraft = get (). newSessionDraft
if ( previousDraft . preparedChatDirectory && target . projectId !== CHAT_DRAFT_PROJECT_ID ) {
void deleteChatDirectory ( previousDraft . preparedChatDirectory ). catch (() => undefined )
}
2026-04-01 00:06:10 -07:00
let nextDirectory : string | null = null
set (( s ) => {
nextDirectory = normalizePath ( target . directoryOverride ?? s . newSessionDraft . directoryOverride )
return {
newSessionDraft : {
... s . newSessionDraft ,
2026-08-21 12:12:40 +03:00
target : target.projectId === CHAT_DRAFT_PROJECT_ID ? "chat" : "project" ,
preparedChatDirectory : target.projectId === CHAT_DRAFT_PROJECT_ID ? s.newSessionDraft.preparedChatDirectory : null ,
2026-04-01 00:06:10 -07:00
selectedProjectId : target.projectId ?? target . selectedProjectId ?? s . newSessionDraft . selectedProjectId ,
2026-08-21 12:12:40 +03:00
directoryOverride : target.projectId === CHAT_DRAFT_PROJECT_ID ? null : target . directoryOverride ?? s . newSessionDraft . directoryOverride ,
2026-04-01 00:06:10 -07:00
},
}
})
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-08-19 00:07:57 +03:00
setDraftProjectContextPin : ( kind , id , pinned ) =>
set (( s ) => {
if ( ! s . newSessionDraft ? . open ) return s
const pins = s . newSessionDraft . projectContextPins ?? { notes : [], plans : [] }
const key = kind === "note" ? "notes" : "plans"
const next = new Set ( pins [ key ])
if ( pinned ) next . add ( id )
else next . delete ( id )
return {
newSessionDraft : {
... s . newSessionDraft ,
projectContextPins : { ... pins , [ key ] : [... next ] },
},
}
}),
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
2026-08-14 23:06:56 +02:00
type AssistantTokens = { total? : number ; input : number ; output : number ; reasoning : number ; cache : { read : number ; write : number } }
2026-03-31 18:47:00 +03:00
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
2026-08-14 23:06:56 +02:00
const total = contextTokensFromBreakdown ( tokens )
2026-03-31 18:47:00 +03:00
if ( total > 0 ) {
lastTokens = tokens
lastMessageId = msg . id
break
}
}
if ( ! lastTokens ) return null
2026-08-14 23:06:56 +02:00
const totalTokens = contextTokensFromBreakdown ( lastTokens )
2026-03-31 18:47:00 +03:00
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-08-07 05:46:21 +08:00
const capturedTarget = options ? . target
if ( capturedTarget && capturedTarget . runtimeKey !== getRuntimeKey ()) {
throw new Error ( "Message was not sent because the runtime changed." )
}
2026-04-22 03:34:06 +08:00
// Clear non-Git changed-files bar on new user message for current session
2026-08-07 05:46:21 +08:00
const sid = capturedTarget ? . sessionId ?? 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-08-13 12:31:33 +03:00
const draft = options ? . draftSnapshot ?? get (). newSessionDraft
2026-03-31 18:47:00 +03:00
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 }]
}
2026-07-29 00:47:29 +03:00
const applyArmedGoal = async ( goalSessionId : string , goalDirectory : string | null | undefined ) => {
2026-07-12 01:23:22 +03:00
if ( ! goalArmed ) return
const uiState = useUIStore . getState ()
const tokenBudget = uiState . sessionGoalDefaultBudgetEnabled ? uiState.sessionGoalDefaultBudget : null
2026-07-29 00:47:29 +03:00
let objective = goalArm . objectiveOverride ? . trim () || content
if ( ! goalArm . objectiveOverride && content . startsWith ( "/" )) {
const directoryCommands = getDirectoryState ( goalDirectory ?? undefined ) ? . command ?? []
const storedCommands = useCommandsStore . getState (). commands
const knownCommands = [... directoryCommands , ... storedCommands ]
objective = expandSlashCommandGoalObjective ( content , knownCommands )
if ( objective === content ) {
try {
objective = expandSlashCommandGoalObjective (
content ,
await opencodeClient . listCommandsWithDetails ( goalDirectory ),
)
} catch {
// Command dispatch remains authoritative; raw invocation is a safe objective fallback.
}
}
}
try {
await setSessionGoal ( goalSessionId , goalDirectory ?? undefined , { objective , tokenBudget }, null )
} catch ( error ) {
useSessionGoalArmStore . getState (). setArmed ( true , goalArm . objectiveOverride )
throw error
}
2026-07-12 01:23:22 +03:00
}
2026-03-31 18:47:00 +03:00
// ---- New session from draft ----
2026-08-07 05:46:21 +08:00
if ( ! capturedTarget && ! options ? . sessionId && draft ? . open ) {
2026-06-26 19:52:39 +11:00
const createdDraftSession = await materializeOpenDraftSession ({
providerID ,
modelID ,
agent : trimmedAgent ,
variant ,
2026-08-13 12:31:33 +03:00
}, options ? . draftSnapshot )
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-08-18 02:59:04 +03:00
const draftParts = createdDraftSession . syntheticParts ? . length
2026-06-26 19:52:39 +11:00
? [...( additionalParts || []), ... createdDraftSession . syntheticParts ]
2026-03-31 18:47:00 +03:00
: additionalParts
2026-08-18 02:59:04 +03:00
// The server decides what this session still owes and assembles it; the
// client only carries it and reports it delivered.
const draftKnowledge = await fetchSessionKnowledge (
createdDraftSession . directory ,
createdDraftSession . sessionId ,
)
const draftPrefixParts : Array < { text : string ; attachments? : AttachedFile []; synthetic? : boolean } > =
draftKnowledge . text ? [{ text : draftKnowledge.text , synthetic : true }] : []
// Left undefined when nothing was added, as before: an empty array is not
// the same as no additional parts to everything downstream.
const mergedAdditionalParts = draftPrefixParts . length > 0
? [... draftPrefixParts , ...( draftParts || [])]
: draftParts
2026-03-31 18:47:00 +03:00
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 ,
}))
2026-07-29 00:47:29 +03:00
await applyArmedGoal ( createdDraftSession . sessionId , createdDraftSession . directory )
2026-03-31 18:47:00 +03:00
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-08-18 02:59:04 +03:00
// Recorded only after the send resolves: a failed send must carry the
// pinned context again rather than assume the agent already saw it.
if ( draftKnowledge . text ) {
void reportSessionKnowledgeDelivered (
createdDraftSession . directory ,
createdDraftSession . sessionId ,
draftKnowledge . signature ,
)
}
2026-03-31 18:47:00 +03:00
return
}
// ---- Existing session ----
2026-08-07 05:46:21 +08:00
const targetSessionId = capturedTarget ? . sessionId ?? options ? . sessionId ?? get (). currentSessionId
2026-05-25 00:29:52 +03:00
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-08-07 05:46:21 +08:00
? normalizePath ( capturedTarget ? . directory ?? 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 ,
}))
2026-07-29 00:47:29 +03:00
if ( targetSessionId ) {
await applyArmedGoal ( targetSessionId , currentSessionDirectory )
}
2026-08-18 02:59:04 +03:00
// Standing project context — pinned notes and plans, and the memory index.
// Prepended so it reads as background before the message it accompanies,
// and empty unless the session is actually missing it.
const knowledge = await fetchSessionKnowledge ( currentSessionDirectory , targetSessionId || "" )
const prefixParts : Array < { text : string ; attachments? : AttachedFile []; synthetic? : boolean } > =
knowledge . text ? [{ text : knowledge.text , synthetic : true }] : []
const partsWithPinnedContext = prefixParts . length > 0
? [... prefixParts , ...( additionalParts || [])]
: additionalParts
2026-03-31 18:47:00 +03:00
await routeMessage ({
2026-08-07 05:46:21 +08:00
runtimeKey : capturedTarget?.runtimeKey ,
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-08-18 02:59:04 +03:00
additionalParts : partsWithPinnedContext?.map (( p ) => ({
2026-03-31 18:47:00 +03:00
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-08-18 02:59:04 +03:00
if ( knowledge . text ) {
void reportSessionKnowledgeDelivered ( currentSessionDirectory , targetSessionId || "" , knowledge . signature )
}
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 {
2026-08-15 04:26:39 +00:00
const resolved = await resolveCreatableDraftDirectory ( draft , directoryOverride )
if ( resolved . status === "aborted" ) return null
const dir = resolved . directory
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 ) {
2026-08-15 04:26:39 +00:00
const scopeKey = dir || get (). lastLoadedDirectory || session . directory
2026-03-31 18:47:00 +03:00
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
// ---------------------------------------------------------------------------
2026-08-19 00:07:57 +03:00
deleteSession : async ( id , options ) => deleteSessionAction ( id , options ),
2026-08-02 15:13:06 +00:00
2026-08-18 02:59:04 +03:00
deleteSessions : async ( ids , options ) => {
const result = await deleteSessionsAction ( ids , options )
return result
},
2026-03-31 18:47:00 +03:00
archiveSession : ( id ) => archiveSessionAction ( id ),
2026-08-02 11:55:02 +00:00
archiveSessions : ( ids , options ) => archiveSessionsAction ( ids , options ),
2026-03-31 18:47:00 +03:00
2026-08-04 13:21:04 +03:00
unarchiveSession : ( id ) => unarchiveSessionAction ( id ),
unarchiveSessions : ( ids , options ) => unarchiveSessionsAction ( ids , options ),
2026-03-31 18:47:00 +03:00
// ---------------------------------------------------------------------------
// 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-08-14 16:53:05 +03:00
const revertIndex = userMessages . findIndex (( message ) => message . id === revertToId )
targetMessage = revertIndex > 0 ? userMessages [ revertIndex - 1 ] : undefined
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-08-14 16:53:05 +03:00
const revertIndex = userMessages . findIndex (( message ) => message . id === revertToId )
const targetMessage = revertIndex >= 0 ? userMessages [ revertIndex + 1 ] : undefined
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-08-03 12:50:48 +03:00
// The selection-time directory participates in resolution, it does not
// short-circuit it. For a worktree session selected before its directory
// store finished bootstrapping, that value is a startup fallback pointing
// at the parent repository; letting it win would route every send, queue
// key, and send-confirmation lookup to a directory that does not own the
// session.
const selected = sessionId === get (). currentSessionId ? get (). currentSessionDirectory : null
const resolved = resolveSessionDirectory (
sessionId ,
( sid ) => get (). worktreeMetadata . get ( sid ),
selected ,
)
2026-06-03 22:38:15 +03:00
if ( resolved ) return resolved
2026-05-13 03:12:30 -05:00
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 )
2026-08-04 12:46:48 +00:00
const choice = findLatestUserModelChoice (
messages ,
( messageId ) => getSyncParts ( messageId , directory ),
)
if ( ! choice ) {
return null
}
return {
agent : choice.agent ,
providerID : choice.providerID ,
modelID : choice.modelID ,
variant : choice.variant ,
2026-03-31 18:47:00 +03:00
}
},
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-08-04 00:42:34 +03:00
adoptAuthoritativeSessionDirectory : ( sessionId ) => {
const target = sessionId ?? get (). currentSessionId
// Only a guess is promoted. A confirmed selection outranks anything sync
// learns later, and a selection that has since moved on must not be
// rewritten by a directory that finished bootstrapping in the background.
if ( ! target || target !== guessedSelectionSessionId ) return
if ( target !== get (). currentSessionId ) return
const authoritative = getAuthoritativeSessionDirectory ( target )
if ( ! authoritative ) return
// The selection stops being a guess even when the directory is unchanged:
// the value has now been confirmed by the store that owns the session.
guessedSelectionSessionId = null
if ( authoritative !== get (). currentSessionDirectory ) {
set ({ currentSessionDirectory : authoritative })
}
writeRuntimeSessionMemory ( runtimeMemoryKey (), { sessionId : target , directory : authoritative })
},
2026-06-03 22:38:15 +03:00
setSessionDirectory : ( sessionId , directory ) => {
const normalized = normalizePath ( directory )
2026-08-03 12:50:48 +03:00
// Callers set this from a confirmed destination (a completed move, a
// created worktree), so the selection is no longer a guess.
if ( sessionId === guessedSelectionSessionId ) {
guessedSelectionSessionId = null
}
2026-06-03 22:38:15 +03:00
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
}
})