* 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>
1557 lines
60 KiB
TypeScript
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)
|
|
}
|
|
})
|