Files
openchamber/packages/ui/src/sync/session-ui-store.ts
T
f13f6d5540 feat(#1766): support OpenCode steer delivery / follow-up behavior settings (#1781)
* feat: support OpenCode steer delivery / follow-up behavior settings

Implements issue #1766 — steer delivery mode for mid-turn message
insertion, replacing the old boolean queue-mode toggle with a tri-state
follow-up behavior setting (Steer / Queue / Send immediately).

- Plumbing: threaded optional delivery: 'steer' through sendMessage
  -> routeMessage -> opencodeClient.sendMessage -> promptAsync
- Store: messageQueueStore stores followUpBehavior; migration from
  legacy queueModeEnabled persisted state
- Settings: Chat -> Follow-up behavior shows three radio options
  using existing settings UI patterns
- Composer: when session is busy, a floating queue button remains;
  force-sending a queued message (via chip click) uses delivery: 'steer'
  during a busy session; Steer button intentionally omitted — steer is
  available via the two-gesture path (Enter to queue -> chip to steer)
- Keyboard: queue mode = Enter queues, Ctrl+Enter sends; otherwise
  Enter sends, Ctrl+Enter queues
- Persistence: DesktopSettings, web settings payload, and server-side
  sanitizer handle the new key with legacy fallback
- i18n: follow-up behavior section and option labels in all 9 locales
  plus new chat.chatInput.actions.queue label
- Search: settings registry updated from chat.queue-mode to
  chat.follow-up-behavior

Validation: type-check passes (no new errors), lint clean.

* fix(#1766): make steer mode actually steer

The followUpBehavior === 'steer' branch in handlePrimaryAction and the
keyboard handler was a no-op — both fell into the else branch and sent
without the delivery: 'steer' flag, so selecting 'Steer (insert into
the running turn)' in settings produced identical behavior to 'Send
immediately'.

- handlePrimaryAction: when steer mode is selected and the session is
  busy, call handleSubmit({ delivery: 'steer' }) directly
- Keyboard handler: in steer mode, Enter steers and Ctrl+Enter sends
  immediately (consistent with queue mode where Ctrl+Enter bypasses
  the special handling)

Also removes the unused chat.chatInput.actions.queue i18n key from all
9 locales (it was a dead key after the Steer button was removed from
the composer).

Validation: type-check clean, lint clean.

* refactor(#1766): flatten nested ternary in followUpBehavior resolution

Replace nested ternary with explicit if/else chain per project code style
(CONTRIBUTING.md). Import FollowUpBehavior type explicitly for the new
let declaration.

* feat(chat): drop redundant 'immediate' follow-up mode, keep Queue + Steer

'Immediate' was wire-identical to 'Steer' on a busy session: OpenCode only
supports delivery 'steer' | 'queue' and defaults to 'steer', so an immediate
send (no delivery flag) already steered into the running turn. The three-mode
UI therefore exposed two settings that did the same thing.

Collapse to two modes — Queue (unchanged: client-side queue with edit/reorder)
and Steer. Any persisted/legacy 'immediate' (and legacy queueModeEnabled=false)
now maps to 'steer', preserving prior behavior. Removes the immediate option,
its keyboard branch, the i18n label across all locales, and narrows the
followUpBehavior union to 'steer' | 'queue'.

---------

Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-06-29 01:28:20 +03:00

1557 lines
60 KiB
TypeScript

/**
* 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.
*
* Session↔worktree attachments are the authoritative exception: they live in
* session-worktree-store (shared sync), and session-ui-store routes through it.
*
* SDK-calling actions that need domain data read it from sync-refs.
*/
import { create } from "zustand"
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
import type { WorktreeMetadata } from "@/types/worktree"
import { opencodeClient } from "@/lib/opencode/client"
import { runtimeFetch } from "@/lib/runtime-fetch"
import { useConfigStore } from "@/stores/useConfigStore"
import { useProjectsStore } from "@/stores/useProjectsStore"
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from "@/stores/useGlobalSessionsStore"
import { useDirectoryStore } from "@/stores/useDirectoryStore"
import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore"
import { useCommandsStore } from "@/stores/useCommandsStore"
import { useSkillsStore } from "@/stores/useSkillsStore"
import { getSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
import { getWorktreeSetupWaitEnabled } from "@/lib/openchamberConfig"
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
import {
getSyncSessions,
getAllSyncSessions,
getSyncMessages,
getSyncParts,
getDirectoryState,
} from "./sync-refs"
import { markSessionViewed } from "./notification-store"
import { setActiveSession } from "./sync-context"
import {
createSession as createSessionAction,
deleteSession as deleteSessionAction,
archiveSession as archiveSessionAction,
updateSessionTitle as updateSessionTitleAction,
shareSession as shareSessionAction,
unshareSession as unshareSessionAction,
optimisticSend,
refetchSessionMessages,
revertToMessage as revertToMessageAction,
unrevertSession as unrevertSessionAction,
forkFromMessage as forkFromMessageAction,
fetchMessagesForSession,
} from "./session-actions"
import { useInputStore, type SyntheticContextPart } from "./input-store"
import { useSelectionStore } from "./selection-store"
import { getViewportSessionMemory, useViewportStore, viewportSessionKey } from "./viewport-store"
import { useSessionWorktreeStore } from "./session-worktree-store"
import { getAttachedSessionDirectory } from "./session-worktree-contract"
import { setSessionOpener } from "./session-navigation"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
export type { AttachedFile }
// ---------------------------------------------------------------------------
// Send routing — shell mode, slash commands, or normal prompt
// ---------------------------------------------------------------------------
export function routeMessage(params: {
sessionId: string
directory?: string | null
content: string
providerID: string
modelID: string
agent?: string
agentMentionName?: string
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 }> }>
delivery?: 'steer'
}): Promise<void> {
const requestDirectory = params.directory ?? undefined
if (params.inputMode === "shell") {
return opencodeClient.shellSession({
sessionId: params.sessionId,
directory: requestDirectory,
agent: params.agent ?? "",
model: { providerID: params.providerID, modelID: params.modelID },
command: params.content,
}).then(() => undefined)
}
// 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)
const dirState = getDirectoryState(requestDirectory)
const syncCommands = dirState?.command ?? []
const storeCommands = useCommandsStore.getState().commands
// 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).
const isCommand = syncCommands.find((c) => c.name === cmdName)
|| storeCommands.find((c) => c.name === cmdName)
|| useSkillsStore.getState().skills.some((s) => s.name === cmdName)
if (isCommand) {
return optimisticSend({
sessionId: params.sessionId,
content: params.content,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent,
directory: requestDirectory,
files: params.files,
send: (messageID) => opencodeClient.sendCommand({
id: params.sessionId,
providerID: params.providerID,
modelID: params.modelID,
command: cmdName,
arguments: tail.join(" "),
agent: params.agent,
variant: params.variant,
files: params.files,
messageId: messageID,
directory: requestDirectory,
}).then(() => {}),
})
}
}
// Normal prompt — optimistic insert so message appears instantly
return optimisticSend({
sessionId: params.sessionId,
content: params.content,
providerID: params.providerID,
modelID: params.modelID,
agent: params.agent,
directory: requestDirectory,
files: params.files,
send: (messageID) => opencodeClient.sendMessage({
id: params.sessionId,
providerID: params.providerID,
modelID: params.modelID,
text: params.content,
agent: params.agent,
agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined,
variant: params.variant,
files: params.files,
additionalParts: params.additionalParts,
delivery: params.delivery,
messageId: messageID,
directory: requestDirectory,
}).then(() => {}),
})
}
type SendMessageOptions = {
sessionId?: string
delivery?: 'steer'
}
type AssistantMessageSessionExecution = {
providerID: string
modelID: string
variant: string
agent: string
instructions: string
createWorktree?: boolean
}
function notifyMessageSent(sessionId: string): void {
runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" })
.catch(() => { /* ignore */ })
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type { SyntheticContextPart } from "./input-store"
export type { SessionMemoryState } from "./viewport-store"
export type { VoiceStatus, VoiceMode } from "./voice-store"
export type NewSessionDraftState = {
open: boolean
selectedProjectId?: string | null
directoryOverride: string | null
pendingWorktreeRequestId?: string | null
bootstrapPendingDirectory?: string | null
preserveDirectoryOverride?: boolean
parentID: string | null
title?: string
initialPrompt?: string
syntheticParts?: SyntheticContextPart[]
targetFolderId?: string
}
export type ViewportAnchor = {
sessionId: string
value: number
}
export type SessionHistoryMeta = {
limit: number
hasMore: boolean
complete: boolean
isLoading: boolean
loading?: boolean
nextCursor?: string
}
export type SessionUIState = {
currentSessionId: string | null
currentSessionDirectory: string | null
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
// 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
// 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
// Actions — UI state management
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState>) => void
closeNewSessionDraft: () => void
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
setDraftPreserveDirectoryOverride: (value: boolean) => void
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",
options?: SendMessageOptions,
) => Promise<void>
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null, metadata?: Record<string, unknown>) => Promise<Session | null>
deleteSession: (id: string, options?: Record<string, unknown>) => Promise<boolean>
deleteSessions: (ids: string[], options?: Record<string, unknown>) => Promise<{ deletedIds: string[]; failedIds: string[] }>
archiveSession: (id: string) => Promise<boolean>
archiveSessions: (ids: string[], options?: Record<string, unknown>) => Promise<{ archivedIds: string[]; failedIds: string[] }>
updateSessionTitle: (sessionId: string, title: string) => Promise<void>
shareSession: (sessionId: string) => Promise<Session | null>
unshareSession: (sessionId: string) => Promise<Session | null>
revertToMessage: (sessionId: string, messageId: string, options?: { skipRedoPush?: boolean }) => Promise<void>
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>
handleSlashUndo: (sessionId: string) => Promise<void>
handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise<void>
createSessionFromAssistantMessage: (sourceMessageId: string, execution: AssistantMessageSessionExecution) => Promise<void>
// Data access helpers (read from sync)
getSessionsByDirectory: (directory: string) => Session[]
getDirectoryForSession: (sessionId: string) => string | null
getLastUserChoice: (sessionId: string) => { agent?: string; providerID?: string; modelID?: string; variant?: string } | null
getCurrentAgent: (sessionId: string) => string | undefined
debugSessionMessages: (sessionId: string) => Promise<void>
pollForTokenUpdates: () => void
setSessionDirectory: (sessionId: string, directory: string | null) => void
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const normalizePath = (value?: string | null): string | null => {
if (typeof value !== "string") return null
const trimmed = value.trim()
if (!trimmed) return null
const replaced = trimmed.replace(/\\/g, "/")
if (replaced === "/") return "/"
return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced
}
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)
}
const safeStorage = getSafeStorage()
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 */ }
}
const resolveDraftProjectForDirectory = resolveProjectForSessionDirectory
const getAttachmentForSession = (sessionId: string | null | undefined): SessionWorktreeAttachment | undefined => {
if (!sessionId) return undefined
return useSessionWorktreeStore.getState().getAttachment(sessionId)
}
const resolveSessionDirectory = (
sessionId: string | null | undefined,
getWtMeta: (id: string) => WorktreeMetadata | undefined,
): string | null => {
if (!sessionId) return null
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
if (attachmentDirectory) return attachmentDirectory
const metaPath = getWtMeta(sessionId)?.path
if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath)
const runtimeMemory = runtimeSessionMemory.get(runtimeMemoryKey())
if (runtimeMemory?.sessionId === sessionId && runtimeMemory.directory) {
return normalizePath(runtimeMemory.directory)
}
const sessions = getAllSyncSessions()
const target = sessions.find((s) => s.id === sessionId)
if (!target) return null
return resolveDirectoryKey(target)
}
const activateConfigForDirectory = async (directory: string | null | undefined): Promise<void> => {
await useConfigStore.getState().activateDirectory(normalizePath(directory))
}
const DEFAULT_DRAFT: NewSessionDraftState = {
open: false,
directoryOverride: null,
parentID: null,
}
const activeSessionByRuntime = new Map<string, string | null>()
type RuntimeSessionMemory = {
sessionId: string | null
directory: string | null
draft: NewSessionDraftState
}
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 },
...patch,
})
}
type MaterializedDraftSession = {
sessionId: string
directory: string | null
agent?: string
syntheticParts?: SyntheticContextPart[]
}
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)
}
}
export async function materializeOpenDraftSession(selection: {
providerID: string
modelID: string
agent?: string
variant?: string
}): Promise<MaterializedDraftSession | null> {
const store = useSessionUIStore.getState()
const draft = store.newSessionDraft
if (!draft?.open) return null
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)
}
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
if (!created?.id) throw new Error("Failed to create session")
persistDraftTarget({
projectId: draftProjectId,
directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null),
})
const draftSyntheticParts = draft.syntheticParts
const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null)
const configState = useConfigStore.getState()
void activateConfigForDirectory(createdDirectory).catch((error) => {
console.warn("Failed to activate directory after creating session:", error)
})
const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName
useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID)
if (effectiveDraftAgent) {
useSelectionStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent)
useSelectionStore.getState().saveAgentModelForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID)
useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, selection.variant)
}
store.initializeNewOpenChamberSession(created.id, configState.agents ?? [])
store.setCurrentSession(created.id, createdDirectory)
return {
sessionId: created.id,
directory: createdDirectory,
agent: effectiveDraftAgent,
syntheticParts: draftSyntheticParts,
}
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// 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 WORKTREE_MAP_STORAGE_KEY = 'oc.worktreeMap'
const loadPersistedWorktreeMap = (): Map<string, WorktreeMetadata[]> => {
try {
const raw = getSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY)
if (!raw) return new Map()
const entries = JSON.parse(raw) as Array<[string, WorktreeMetadata[]]>
if (!Array.isArray(entries)) return new Map()
return new Map(
entries.filter((entry) => Array.isArray(entry) && typeof entry[0] === 'string' && Array.isArray(entry[1])),
)
} catch {
return new Map()
}
}
const persistWorktreeMap = (map: Map<string, WorktreeMetadata[]>): void => {
try {
getSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()]))
} catch {
// quota / serialization error — ignore; discovery still refreshes at runtime
}
}
const flattenWorktreeMap = (map: Map<string, WorktreeMetadata[]>): WorktreeMetadata[] => {
const out: WorktreeMetadata[] = []
for (const list of map.values()) out.push(...list)
return out
}
const PERSISTED_WORKTREE_MAP = loadPersistedWorktreeMap()
export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
currentSessionId: null,
currentSessionDirectory: null,
newSessionDraft: { ...DEFAULT_DRAFT },
abortPromptSessionId: null,
abortPromptExpiresAt: null,
error: null,
worktreeMetadata: new Map(),
availableWorktrees: flattenWorktreeMap(PERSISTED_WORKTREE_MAP),
availableWorktreesByProject: PERSISTED_WORKTREE_MAP,
webUICreatedSessions: new Set(),
sessionAbortFlags: new Map(),
abortControllers: new Map(),
isLoading: false,
lastLoadedDirectory: null,
sessionPlanAvailable: new Map(),
pendingChangesBarDismissed: new Map(),
// ---------------------------------------------------------------------------
// setCurrentSession
// ---------------------------------------------------------------------------
setCurrentSession: (id, directoryHint?: string | null) => {
if (id) {
get().closeNewSessionDraft()
}
const key = runtimeMemoryKey()
activeSessionByRuntime.set(key, id)
const previousSessionId = get().currentSessionId
const directoryState = useDirectoryStore.getState()
const sessionDir = resolveSessionDirectory(
id,
(sid) => get().worktreeMetadata.get(sid),
)
const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null
const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir
const projectsState = useProjectsStore.getState()
const sessionProject = resolvedDir
? resolveProjectForSessionDirectory(
projectsState.projects,
get().availableWorktreesByProject,
resolvedDir,
)
: null
// 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 })
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
// 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)
}
try {
if (resolvedDir && directoryState.currentDirectory !== resolvedDir) {
directoryState.setDirectory(resolvedDir, { showOverlay: false })
}
if (sessionProject && projectsState.activeProjectId !== sessionProject.id) {
projectsState.setActiveProjectIdOnly(sessionProject.id)
}
opencodeClient.setDirectory(resolvedDir ?? undefined)
} catch (e) {
console.warn("Failed to set OpenCode directory for session switch:", e)
}
// Defer viewport anchor save for previous session — not needed for the
// skeleton to render and reads messages which can be expensive.
if (previousSessionId && previousSessionId !== id) {
const prevId = previousSessionId
setTimeout(() => {
const memState = getViewportSessionMemory(prevId)
if (!memState?.isStreaming) {
const prevMessages = getSyncMessages(prevId)
if (prevMessages.length > 0) {
useViewportStore.getState().updateViewportAnchor(prevId, prevMessages.length - 1)
}
}
}, 0)
}
// Mark session viewed in notification store + update active session ref
if (id) {
markSessionViewed(id)
setActiveSession(resolvedDir ?? "", id)
}
},
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),
})
},
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
if (restoredDirectory) {
useDirectoryStore.getState().setDirectory(restoredDirectory, { showOverlay: false })
}
set({
currentSessionId: restoredSessionId,
currentSessionDirectory: restoredSessionId ? restoredDirectory : null,
newSessionDraft: restoredSessionId ? { ...DEFAULT_DRAFT } : restoredDraft,
abortPromptSessionId: null,
abortPromptExpiresAt: null,
error: null,
sessionAbortFlags: new Map(),
pendingChangesBarDismissed: new Map(),
})
if (restoredSessionId) {
setActiveSession(restoredDirectory ?? opencodeClient.getDirectory() ?? "", restoredSessionId)
} else {
setActiveSession("", "")
}
},
// ---------------------------------------------------------------------------
// openNewSessionDraft
// ---------------------------------------------------------------------------
openNewSessionDraft: (options) => {
const projectsState = useProjectsStore.getState()
const projects = projectsState.projects
const availableWorktreesByProject = get().availableWorktreesByProject
const activeProject = projectsState.getActiveProject()
const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null)
const persistedTarget = readPersistedDraftTarget()
const explicitDirectory = options?.directoryOverride !== undefined
? normalizePath(options.directoryOverride)
: null
const explicitProject = options?.selectedProjectId
? projects.find((p) => p.id === options.selectedProjectId) ?? null
: null
const inferredProjectFromDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory)
const fallbackProject = (() => {
if (activeProject) return activeProject
if (projectsState.activeProjectId) return projects.find((p) => p.id === projectsState.activeProjectId) ?? null
return projects[0] ?? null
})()
const persistedProjectById = persistedTarget?.projectId
? projects.find((p) => p.id === persistedTarget.projectId) ?? null
: null
const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null)
const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory)
const selectedProject = (() => {
if (explicitProject) return explicitProject
if (explicitDirectory !== null) return inferredProjectFromDir
if (currentDirectory) return currentDirProject
return persistedProjectByDir ?? persistedProjectById ?? fallbackProject
})()
const directory = (() => {
if (explicitDirectory !== null) return explicitDirectory
if (explicitProject) return normalizePath(explicitProject.path ?? null)
if (currentDirectory) return currentDirectory
if (persistedTarget?.directory) return persistedTarget.directory
return normalizePath(selectedProject?.path ?? null)
})()
persistDraftTarget({ projectId: selectedProject?.id ?? null, directory })
const nextDraft: NewSessionDraftState = {
open: true,
selectedProjectId: selectedProject?.id ?? null,
directoryOverride: directory,
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,
}
set({
newSessionDraft: {
...nextDraft,
},
currentSessionId: null,
currentSessionDirectory: null,
error: null,
})
writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId: null, directory, draft: nextDraft })
// 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()
if (options?.initialPrompt) {
useInputStore.getState().setPendingInputText(options.initialPrompt)
}
// 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(() => {
useConfigStore.getState().applyDefaultModelAgentSelection()
})
if (directory && directory !== useDirectoryStore.getState().currentDirectory) {
useDirectoryStore.getState().setDirectory(directory)
}
},
// ---------------------------------------------------------------------------
// closeNewSessionDraft
// ---------------------------------------------------------------------------
closeNewSessionDraft: () => {
const nextDraft: NewSessionDraftState = {
open: false,
selectedProjectId: null,
directoryOverride: null,
pendingWorktreeRequestId: null,
bootstrapPendingDirectory: null,
preserveDirectoryOverride: false,
parentID: null,
title: undefined,
initialPrompt: undefined,
syntheticParts: undefined,
targetFolderId: undefined,
}
set({
newSessionDraft: nextDraft,
})
writeRuntimeSessionMemory(runtimeMemoryKey(), { draft: nextDraft })
},
setNewSessionDraftTarget: (target) => {
let nextDirectory: string | null = null
set((s) => {
nextDirectory = normalizePath(target.directoryOverride ?? s.newSessionDraft.directoryOverride)
return {
newSessionDraft: {
...s.newSessionDraft,
selectedProjectId: target.projectId ?? target.selectedProjectId ?? s.newSessionDraft.selectedProjectId,
directoryOverride: target.directoryOverride ?? s.newSessionDraft.directoryOverride,
},
}
})
void activateConfigForDirectory(nextDirectory)
if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) {
useDirectoryStore.getState().setDirectory(nextDirectory)
}
},
setDraftPreserveDirectoryOverride: (value) =>
set((s) => {
if (!s.newSessionDraft?.open) return s
return { newSessionDraft: { ...s.newSessionDraft, preserveDirectoryOverride: value } }
}),
acknowledgeSessionAbort: (sessionId) =>
set((s) => {
const flags = new Map(s.sessionAbortFlags)
const existing = flags.get(sessionId)
if (existing) flags.set(sessionId, { ...existing, acknowledged: true })
return { sessionAbortFlags: flags }
}),
clearAbortPrompt: () => set({ abortPromptSessionId: null, abortPromptExpiresAt: null }),
armAbortPrompt: (durationMs = 5000) => {
const { currentSessionId } = get()
if (!currentSessionId) return null
const expiresAt = Date.now() + durationMs
set({ abortPromptSessionId: currentSessionId, abortPromptExpiresAt: expiresAt })
return expiresAt
},
clearError: () => set({ error: null }),
markSessionAsOpenChamberCreated: (sessionId) =>
set((s) => {
const next = new Set(s.webUICreatedSessions)
next.add(sessionId)
return { webUICreatedSessions: next }
}),
isOpenChamberCreatedSession: (sessionId) => get().webUICreatedSessions.has(sessionId),
getContextUsage: (contextLimit: number, outputLimit: number) => {
if (get().newSessionDraft?.open) return null
const sessionId = get().currentSessionId
if (!sessionId) return null
const messages = getSyncMessages(sessionId)
if (messages.length === 0) return null
type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
let lastTokens: AssistantTokens | undefined
let lastMessageId: string | undefined
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
const tokens = (msg as { tokens?: AssistantTokens }).tokens
if (!tokens) continue
const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0)
if (total > 0) {
lastTokens = tokens
lastMessageId = msg.id
break
}
}
if (!lastTokens) return null
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0)
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined
return {
totalTokens,
percentage,
contextLimit: contextLimit || 0,
outputLimit: outputLimit || undefined,
normalizedOutput,
thresholdLimit,
lastMessageId,
}
},
initializeNewOpenChamberSession: () => {
// Stub — was a no-op in old store
},
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
set((s) => {
const map = new Map(s.worktreeMetadata)
if (metadata) map.set(sessionId, metadata)
else map.delete(sessionId)
return { worktreeMetadata: map }
})
},
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)
if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) {
useDirectoryStore.getState().setDirectory(nextDirectory)
}
},
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),
dismissPendingChangesBar: (sessionId, signature) => {
const map = new Map(get().pendingChangesBarDismissed);
if (signature === null) {
map.delete(sessionId);
} else {
map.set(sessionId, signature);
}
set({ pendingChangesBarDismissed: map });
},
// ---------------------------------------------------------------------------
// sendMessage — calls SDK, reads domain data from sync
// ---------------------------------------------------------------------------
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",
options?: SendMessageOptions,
) => {
// Clear non-Git changed-files bar on new user message for current session
const sid = options?.sessionId ?? get().currentSessionId;
if (sid) {
const map = new Map(get().pendingChangesBarDismissed);
map.delete(sid);
set({ pendingChangesBarDismissed: map });
}
const draft = get().newSessionDraft
const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined
// ---- New session from draft ----
if (!options?.sessionId && draft?.open) {
const createdDraftSession = await materializeOpenDraftSession({
providerID,
modelID,
agent: trimmedAgent,
variant,
})
if (!createdDraftSession) throw new Error("Failed to create session")
const mergedAdditionalParts = createdDraftSession.syntheticParts?.length
? [...(additionalParts || []), ...createdDraftSession.syntheticParts]
: additionalParts
notifyMessageSent(createdDraftSession.sessionId)
markPendingUserSendAnimation(createdDraftSession.sessionId)
const files = attachments?.map((a) => ({
type: "file" as const,
mime: a.mimeType,
url: a.dataUrl,
filename: a.filename,
}))
await routeMessage({
sessionId: createdDraftSession.sessionId,
directory: createdDraftSession.directory,
content,
providerID,
modelID,
agent: createdDraftSession.agent,
agentMentionName,
variant,
inputMode,
files,
delivery: options?.delivery,
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,
})),
})),
})
return
}
// ---- Existing session ----
const targetSessionId = options?.sessionId ?? get().currentSessionId
const sessionAgentSelection = targetSessionId
? useSelectionStore.getState().getSessionAgentSelection(targetSessionId)
: null
const configAgentName = useConfigStore.getState().currentAgentName
const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined
if (targetSessionId) {
useSelectionStore.getState().saveSessionModelSelection(targetSessionId, providerID, modelID)
}
if (targetSessionId && effectiveAgent) {
useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent)
useSelectionStore.getState().saveAgentModelForSession(targetSessionId, effectiveAgent, providerID, modelID)
useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant)
}
if (targetSessionId) {
const viewportState = useViewportStore.getState()
const memState = getViewportSessionMemory(targetSessionId)
if (!memState || !memState.lastUserMessageAt) {
const newMemState = new Map(viewportState.sessionMemoryState)
newMemState.set(viewportSessionKey(targetSessionId), {
viewportAnchor: 0,
isStreaming: false,
lastAccessedAt: Date.now(),
backgroundMessageCount: 0,
...memState,
lastUserMessageAt: Date.now(),
})
useViewportStore.setState({ sessionMemoryState: newMemState })
}
}
const currentSessionDirectory = targetSessionId
? normalizePath(get().getDirectoryForSession(targetSessionId))
: null
if (targetSessionId) {
notifyMessageSent(targetSessionId)
}
if (targetSessionId) {
markPendingUserSendAnimation(targetSessionId)
}
const files = attachments?.map((a) => ({
type: "file" as const,
mime: a.mimeType,
url: a.dataUrl,
filename: a.filename,
}))
await routeMessage({
sessionId: targetSessionId || "",
directory: currentSessionDirectory,
content,
providerID,
modelID,
agent: effectiveAgent,
agentMentionName,
variant,
inputMode,
files,
delivery: options?.delivery,
additionalParts: additionalParts?.map((p) => ({
text: p.text,
synthetic: p.synthetic,
files: p.attachments?.map((a) => ({
type: "file" as const,
mime: a.mimeType,
url: a.dataUrl,
filename: a.filename,
})),
})),
})
},
// ---------------------------------------------------------------------------
// createSession
// ---------------------------------------------------------------------------
createSession: async (title, directoryOverride, parentID, metadata) => {
const draft = get().newSessionDraft
const targetFolderId = draft.targetFolderId
get().closeNewSessionDraft()
try {
const dir = directoryOverride ?? opencodeClient.getDirectory()
const session = await createSessionAction(title, dir, parentID ?? null, metadata)
if (!session) return null
if (targetFolderId) {
const scopeKey = directoryOverride || get().lastLoadedDirectory || session.directory
if (scopeKey) {
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
}
}
return session
} catch (e) {
console.error("[session-ui-store] createSession failed", e)
return null
}
},
// ---------------------------------------------------------------------------
// deleteSession — calls SDK, SSE event updates child store
// ---------------------------------------------------------------------------
deleteSession: (id) => deleteSessionAction(id),
deleteSessions: async (ids) => {
const deletedIds: string[] = []
const failedIds: string[] = []
for (const id of ids) {
const ok = await deleteSessionAction(id)
if (ok) deletedIds.push(id)
else failedIds.push(id)
}
return { deletedIds, failedIds }
},
archiveSession: (id) => archiveSessionAction(id),
archiveSessions: async (ids) => {
const archivedIds: string[] = []
const failedIds: string[] = []
for (const id of ids) {
const ok = await archiveSessionAction(id)
if (ok) archivedIds.push(id)
else failedIds.push(id)
}
return { archivedIds, failedIds }
},
// ---------------------------------------------------------------------------
// updateSessionTitle — calls SDK, SSE event updates child store
// ---------------------------------------------------------------------------
updateSessionTitle: async (sessionId, title) => {
await updateSessionTitleAction(sessionId, title)
},
shareSession: async (sessionId) => {
return shareSessionAction(sessionId)
},
unshareSession: async (sessionId) => {
return unshareSessionAction(sessionId)
},
// ---------------------------------------------------------------------------
// revertToMessage — delegates to session-actions (single implementation)
// ---------------------------------------------------------------------------
revertToMessage: async (sessionId, messageId) => {
// Ensure the complete message range is present before applying the revert
// marker. Reverted UI is derived from session.revert + stored messages.
await refetchSessionMessages(sessionId)
await revertToMessageAction(sessionId, messageId)
},
// ---------------------------------------------------------------------------
// handleSlashUndo — reads from sync, records history for redo
// ---------------------------------------------------------------------------
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) {
targetMessage = [...userMessages].reverse().find((m) => m.id < revertToId)
} else {
targetMessage = userMessages[userMessages.length - 1]
}
if (!targetMessage) return
// Read target message parts BEFORE calling revertToMessage.
// revertToMessage optimistically deletes messages from the sync store
// before the API call, so getSyncParts must run first.
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]"
// revertToMessage handles the redo stack push internally
await get().revertToMessage(sessionId, targetMessage.id)
const { toast } = await import("sonner")
const { useI18nStore, formatMessage } = await import("@/lib/i18n/store")
const { dictionary } = useI18nStore.getState()
toast.success(formatMessage(dictionary, "chat.revert.toast.undo", { preview }))
},
// ---------------------------------------------------------------------------
// handleSlashRedo — moves the authoritative revert marker forward
// ---------------------------------------------------------------------------
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
}
const sessions = getSyncSessions()
const currentSession = sessions.find((s) => s.id === sessionId)
const revertToId = currentSession?.revert?.messageID
if (!revertToId) return
await refetchSessionMessages(sessionId)
const messages = getSyncMessages(sessionId)
const userMessages = messages.filter((m) => m.role === "user")
const targetMessage = userMessages.find((m) => m.id > revertToId)
if (targetMessage) {
await get().revertToMessage(sessionId, targetMessage.id, { skipRedoPush: true })
const { toast } = await import("sonner")
const { useI18nStore, formatMessage } = await import("@/lib/i18n/store")
const { dictionary } = useI18nStore.getState()
toast.success(formatMessage(dictionary, "chat.revert.toast.redo"))
return
}
await unrevertSessionAction(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"))
},
// ---------------------------------------------------------------------------
// 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 {
await forkFromMessageAction(sessionId, messageId)
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
// ---------------------------------------------------------------------------
createSessionFromAssistantMessage: async (sourceMessageId, execution) => {
if (!sourceMessageId) return
if (!execution?.instructions?.trim()) return
// 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),
)
const sourceWorktreeMetadata = sourceSessionId ? get().worktreeMetadata.get(sourceSessionId) : undefined
const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID
const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID
if (!pID || !mID) return
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)
if (!sessionDirectory) {
throw new Error("Worktree create missing name/path")
}
if (await configModule.getWorktreeSetupWaitEnabled(createdWorktreeProject)) {
await waitForWorktreeBootstrap(sessionDirectory)
}
}
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 })
}
await get().sendMessage(
composeForkSessionMessage(execution.instructions, assistantPlanText),
pID,
mID,
execution.agent || undefined,
undefined,
undefined,
undefined,
execution.variant || undefined,
undefined,
{ sessionId: session.id },
)
},
// ---------------------------------------------------------------------------
// 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) => {
if (sessionId === get().currentSessionId && get().currentSessionDirectory) {
return get().currentSessionDirectory
}
const resolved = resolveSessionDirectory(sessionId, (sid) => get().worktreeMetadata.get(sid))
if (resolved) return resolved
const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId))
if (attachmentDirectory) return attachmentDirectory
const sessions = getAllSyncSessions()
const session = sessions.find((s) => s.id === sessionId)
if (session) return resolveDirectoryKey(session)
const globalStore = useGlobalSessionsStore.getState()
const globalSession = [...globalStore.activeSessions, ...globalStore.archivedSessions]
.find((s) => s.id === sessionId)
if (globalSession) return resolveGlobalSessionDirectory(globalSession)
return null
},
getLastUserChoice: (sessionId) => {
const directory = get().getDirectoryForSession(sessionId) ?? undefined
const messages = getSyncMessages(sessionId, directory)
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i] as Message & {
model?: { providerID?: string; modelID?: string; variant?: string }
variant?: string
mode?: string
}
if (message.role !== "user") {
continue
}
const providerID = typeof message.model?.providerID === "string" && message.model.providerID.trim().length > 0
? message.model.providerID
: undefined
const modelID = typeof message.model?.modelID === "string" && message.model.modelID.trim().length > 0
? message.model.modelID
: undefined
const agent = typeof message.agent === "string" && message.agent.trim().length > 0
? message.agent
: (typeof message.mode === "string" && message.mode.trim().length > 0 ? message.mode : undefined)
const variantCandidate = message.model?.variant ?? message.variant
const variant = typeof variantCandidate === "string" && variantCandidate.trim().length > 0
? variantCandidate
: undefined
return { agent, providerID, modelID, variant }
}
return null
},
getCurrentAgent: (sessionId) => {
return useSelectionStore.getState().sessionAgentSelections.get(sessionId) ?? undefined
},
debugSessionMessages: async (sessionId) => {
const msgs = getSyncMessages(sessionId)
const sessions = getSyncSessions()
const session = sessions.find((s) => s.id === sessionId)
console.log(`Debug session ${sessionId}:`, {
session,
messageCount: msgs.length,
messages: msgs.map((m) => ({
id: m.id,
role: m.role,
tokens: m.role === "assistant" ? m.tokens : undefined,
})),
})
},
pollForTokenUpdates: () => {
// Handled by sync system's SSE stream
},
setSessionDirectory: (sessionId, directory) => {
const normalized = normalizePath(directory)
if (sessionId === get().currentSessionId) {
set({ currentSessionDirectory: normalized })
writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId, directory: normalized })
}
},
// ---------------------------------------------------------------------------
// Plan mode availability tracking
// ---------------------------------------------------------------------------
markSessionPlanAvailable: (sessionId) => {
set((state) => {
if (state.sessionPlanAvailable.get(sessionId) === true) {
return state
}
const next = new Map(state.sessionPlanAvailable)
next.set(sessionId, true)
return { sessionPlanAvailable: next }
})
},
isSessionPlanAvailable: (sessionId) => {
return get().sessionPlanAvailable.get(sessionId) ?? false
},
}))
setSessionOpener((sessionID, directory) => {
useSessionUIStore.getState().setCurrentSession(sessionID, directory)
})
// Write-through persist of the worktree map whenever discovery refreshes it.
// Cheap reference-equality guard — this fires only when the map actually
// changes (discovery / worktree create/remove), not on hot session updates.
useSessionUIStore.subscribe((state, prev) => {
if (state.availableWorktreesByProject !== prev.availableWorktreesByProject) {
persistWorktreeMap(state.availableWorktreesByProject)
}
})