* fix: improve session sidebar tooltip and truncation behavior - Keep new-draft tooltip anchored to its trigger button - Fix minimal-mode worktree/group header text truncation - Tune minimal-mode right padding to reduce early label clipping * fix: render reasoning through markdown pipeline - Use Streamdown rendering for reasoning in live chat mode - Remove italic styling from reasoning text - Render expanded reasoning content with MarkdownRenderer * chore: remove legacy electron dependencies - Removed unused Electron packages from root and UI manifests - Deleted obsolete Electron context menu type declaration - Regenerated lockfile after dependency cleanup * fix: handle non-repository folders in git status API - Prevent 500 errors when status is requested outside a valid Git repo - Improve repository detection using `git rev-parse --git-dir` - Reduce noisy server logs for expected non-repo status checks * fix unloaded session chat layout flicker * fix: reduce noisy TTS status polling Cache and dedupe TTS status requests, and only check provider availability when the related voice features are enabled so disabled voice setups stay quiet. * perf: throttle background PR git status refreshes * fix: improve VS Code Explorer file drop mentions in chat - Add Explorer context action to insert selected files as @mentions. - Handle Explorer drag-and-drop to prefill @file mentions instead of attachments. - Prevent duplicate plain-path text when dropping multiple files. * fix: deduplicate recent sessions in VS Code sidebar - Hide sessions from main list when already shown in recent - Apply dedup only in VS Code runtime - Keep session search behavior unchanged * feat: add true HMR dev flow for VS Code extension - Load VS Code webview from Vite dev server with React refresh preamble - Add `vscode:dev` runner that starts watchers and opens Extension Development Host - Update VS Code dev docs and scripts to use the new HMR startup flow * feat: polish VS Code session sidebar and attachment UX - Add resizable sessions sidebar in VS Code layout - Tighten session list spacing and hover behavior in VS Code - Remove bulk file/image attach success toasts while keeping error toasts
1485 lines
74 KiB
TypeScript
1485 lines
74 KiB
TypeScript
import { create } from "zustand";
|
|
import type { StoreApi, UseBoundStore } from "zustand";
|
|
import { devtools } from "zustand/middleware";
|
|
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
|
|
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
|
|
import type { QuestionRequest } from "@/types/question";
|
|
import type { SessionStore, AttachedFile, EditPermissionMode, SyntheticContextPart } from "./types/sessionTypes";
|
|
|
|
import { useSessionStore as useSessionManagementStore } from "./sessionStore";
|
|
import { useMessageStore } from "./messageStore";
|
|
import { useFileStore } from "./fileStore";
|
|
import { useContextStore } from "./contextStore";
|
|
import { usePermissionStore } from "./permissionStore";
|
|
import { useQuestionStore } from "./questionStore";
|
|
import { opencodeClient } from "@/lib/opencode/client";
|
|
import { useDirectoryStore } from "./useDirectoryStore";
|
|
import { useConfigStore } from "./useConfigStore";
|
|
import { useProjectsStore } from "./useProjectsStore";
|
|
import { useSessionFoldersStore } from "./useSessionFoldersStore";
|
|
import { getSafeStorage } from "./utils/safeStorage";
|
|
import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta";
|
|
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation";
|
|
import { flattenAssistantTextParts } from "@/lib/messages/messageText";
|
|
import { normalizeMessageRecordsForProjection } from "./utils/messageProjectors";
|
|
import type { ProjectEntry } from "@/lib/api/types";
|
|
import type { WorktreeMetadata } from "@/types/worktree";
|
|
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap";
|
|
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree";
|
|
|
|
export type { AttachedFile, EditPermissionMode };
|
|
export { MEMORY_LIMITS, ACTIVE_SESSION_WINDOW } from "./types/sessionTypes";
|
|
|
|
declare global {
|
|
interface Window {
|
|
__zustand_session_store__?: UseBoundStore<StoreApi<SessionStore>>;
|
|
}
|
|
}
|
|
|
|
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 sessionChoiceAnalysisSignature = new Map<string, string>();
|
|
const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget";
|
|
|
|
type PersistedDraftTarget = {
|
|
projectId: string | null;
|
|
directory: string | null;
|
|
};
|
|
|
|
const safeStorage = getSafeStorage();
|
|
|
|
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 resolveProjectForDirectory = (projects: ProjectEntry[], directory: string | null): ProjectEntry | null => {
|
|
const normalizedDirectory = normalizePath(directory);
|
|
if (!normalizedDirectory) {
|
|
return null;
|
|
}
|
|
|
|
let bestMatch: ProjectEntry | null = null;
|
|
for (const project of projects) {
|
|
const projectPath = normalizePath(project.path);
|
|
if (!projectPath) {
|
|
continue;
|
|
}
|
|
const isExact = normalizedDirectory === projectPath;
|
|
const isNested = normalizedDirectory.startsWith(`${projectPath}/`);
|
|
if (!isExact && !isNested) {
|
|
continue;
|
|
}
|
|
if (!bestMatch || projectPath.length > (normalizePath(bestMatch.path)?.length ?? 0)) {
|
|
bestMatch = project;
|
|
}
|
|
}
|
|
return bestMatch;
|
|
};
|
|
|
|
const resolveProjectFromWorktreeDirectory = (
|
|
projects: ProjectEntry[],
|
|
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
|
directory: string | null,
|
|
): ProjectEntry | null => {
|
|
const normalizedDirectory = normalizePath(directory);
|
|
if (!normalizedDirectory) {
|
|
return null;
|
|
}
|
|
|
|
let matchedWorktree: WorktreeMetadata | null = null;
|
|
let matchedProjectPath: string | null = null;
|
|
let bestPathLength = -1;
|
|
|
|
for (const [projectPath, worktrees] of availableWorktreesByProject.entries()) {
|
|
for (const worktree of worktrees) {
|
|
const worktreePath = normalizePath(worktree.path);
|
|
if (!worktreePath) {
|
|
continue;
|
|
}
|
|
const isExact = normalizedDirectory === worktreePath;
|
|
const isNested = normalizedDirectory.startsWith(`${worktreePath}/`);
|
|
if (!isExact && !isNested) {
|
|
continue;
|
|
}
|
|
if (worktreePath.length > bestPathLength) {
|
|
bestPathLength = worktreePath.length;
|
|
matchedWorktree = worktree;
|
|
matchedProjectPath = normalizePath(projectPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!matchedWorktree) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedMetadataProjectPath = normalizePath(matchedWorktree.projectDirectory);
|
|
const candidates = [normalizedMetadataProjectPath, matchedProjectPath].filter((value): value is string => Boolean(value));
|
|
|
|
for (const candidatePath of candidates) {
|
|
const exact = projects.find((project) => normalizePath(project.path) === candidatePath) ?? null;
|
|
if (exact) {
|
|
return exact;
|
|
}
|
|
const nested = resolveProjectForDirectory(projects, candidatePath);
|
|
if (nested) {
|
|
return nested;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const resolveDraftProjectForDirectory = (
|
|
projects: ProjectEntry[],
|
|
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
|
directory: string | null,
|
|
): ProjectEntry | null => {
|
|
return resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory)
|
|
?? resolveProjectForDirectory(projects, directory);
|
|
};
|
|
|
|
const buildSessionChoiceAnalysisSignature = (messages: Array<{ info: Message; parts: Part[] }>): string => {
|
|
const lastMessage = messages[messages.length - 1];
|
|
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
|
|
const lastAssistant = [...messages]
|
|
.reverse()
|
|
.find((message) => message.info?.role === 'assistant');
|
|
const lastAssistantId = typeof lastAssistant?.info?.id === 'string' ? lastAssistant.info.id : '';
|
|
return `${messages.length}:${lastMessageId}:${lastAssistantId}`;
|
|
};
|
|
|
|
const resolveSessionDirectory = (
|
|
sessions: Session[],
|
|
sessionId: string | null | undefined,
|
|
getWorktreeMetadata: (id: string) => { path?: string } | undefined,
|
|
): string | null => {
|
|
if (!sessionId) {
|
|
return null;
|
|
}
|
|
const metadataPath = getWorktreeMetadata(sessionId)?.path;
|
|
if (typeof metadataPath === "string" && metadataPath.trim().length > 0) {
|
|
return normalizePath(metadataPath);
|
|
}
|
|
|
|
const target = sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined;
|
|
if (!target) {
|
|
return null;
|
|
}
|
|
return normalizePath(target.directory ?? null);
|
|
};
|
|
|
|
export const useSessionStore = create<SessionStore>()(
|
|
devtools(
|
|
(set, get) => ({
|
|
|
|
sessions: [],
|
|
archivedSessions: [],
|
|
sessionsByDirectory: new Map(),
|
|
currentSessionId: null,
|
|
lastLoadedDirectory: null,
|
|
messages: new Map(),
|
|
sessionMemoryState: new Map(),
|
|
sessionHistoryMeta: new Map(),
|
|
messageStreamStates: new Map(),
|
|
sessionCompactionUntil: new Map(),
|
|
sessionAbortFlags: new Map(),
|
|
permissions: new Map(),
|
|
questions: new Map(),
|
|
attachedFiles: [],
|
|
isLoading: false,
|
|
error: null,
|
|
streamingMessageIds: new Map(),
|
|
abortControllers: new Map(),
|
|
lastUsedProvider: null,
|
|
isSyncing: false,
|
|
sessionModelSelections: new Map(),
|
|
sessionAgentSelections: new Map(),
|
|
sessionAgentModelSelections: new Map(),
|
|
webUICreatedSessions: new Set(),
|
|
worktreeMetadata: new Map(),
|
|
availableWorktrees: [],
|
|
availableWorktreesByProject: new Map(),
|
|
currentAgentContext: new Map(),
|
|
sessionContextUsage: new Map(),
|
|
sessionAgentEditModes: new Map(),
|
|
abortPromptSessionId: null,
|
|
abortPromptExpiresAt: null,
|
|
sessionStatus: new Map(),
|
|
sessionAttentionStates: new Map(),
|
|
userSummaryTitles: new Map(),
|
|
pendingInputText: null,
|
|
pendingInputMode: 'replace',
|
|
pendingSyntheticParts: null,
|
|
newSessionDraft: { open: true, selectedProjectId: null, directoryOverride: null, pendingWorktreeRequestId: null, bootstrapPendingDirectory: null, preserveDirectoryOverride: false, parentID: null },
|
|
|
|
// Voice state (initialized to disconnected/idle)
|
|
voiceStatus: 'disconnected',
|
|
voiceMode: 'idle',
|
|
|
|
// Voice actions
|
|
setVoiceStatus: (status: import("./types/sessionTypes").VoiceStatus) => {
|
|
set({ voiceStatus: status });
|
|
},
|
|
setVoiceMode: (mode: import("./types/sessionTypes").VoiceMode) => {
|
|
set({ voiceMode: mode });
|
|
},
|
|
|
|
getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => {
|
|
return useContextStore.getState().getSessionAgentEditMode(sessionId, agentName, defaultMode);
|
|
},
|
|
|
|
toggleSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => {
|
|
return useContextStore.getState().toggleSessionAgentEditMode(sessionId, agentName, defaultMode);
|
|
},
|
|
|
|
setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => {
|
|
return useContextStore.getState().setSessionAgentEditMode(sessionId, agentName, mode, defaultMode);
|
|
},
|
|
|
|
loadSessions: () => useSessionManagementStore.getState().loadSessions(),
|
|
|
|
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?.projectId
|
|
? projects.find((project) => project.id === options.projectId) ?? null
|
|
: null;
|
|
|
|
const inferredProjectFromDirectory = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory);
|
|
const fallbackProject = (() => {
|
|
if (activeProject) {
|
|
return activeProject;
|
|
}
|
|
if (projectsState.activeProjectId) {
|
|
return projects.find((project) => project.id === projectsState.activeProjectId) ?? null;
|
|
}
|
|
return projects[0] ?? null;
|
|
})();
|
|
|
|
const persistedProjectById = persistedTarget?.projectId
|
|
? projects.find((project) => project.id === persistedTarget.projectId) ?? null
|
|
: null;
|
|
const persistedProjectByDirectory = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null);
|
|
const currentDirectoryProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory);
|
|
|
|
const selectedProject = (() => {
|
|
if (explicitProject || explicitDirectory !== null) {
|
|
return explicitProject ?? inferredProjectFromDirectory ?? fallbackProject;
|
|
}
|
|
if (currentDirectory) {
|
|
return currentDirectoryProject ?? fallbackProject;
|
|
}
|
|
return persistedProjectByDirectory ?? 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,
|
|
});
|
|
|
|
set({
|
|
newSessionDraft: {
|
|
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,
|
|
},
|
|
currentSessionId: null,
|
|
error: null,
|
|
// Set pending input text if initialPrompt is provided
|
|
...(options?.initialPrompt ? { pendingInputText: options.initialPrompt, pendingInputMode: 'replace' as const } : {}),
|
|
});
|
|
|
|
try {
|
|
const configState = useConfigStore.getState();
|
|
const visibleAgents = configState.getVisibleAgents();
|
|
|
|
// Priority: settingsDefaultAgent → build → first visible
|
|
let agentName: string | undefined;
|
|
if (configState.settingsDefaultAgent) {
|
|
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
|
if (settingsAgent) {
|
|
agentName = settingsAgent.name;
|
|
}
|
|
}
|
|
if (!agentName) {
|
|
agentName =
|
|
visibleAgents.find((agent) => agent.name === 'build')?.name ||
|
|
visibleAgents[0]?.name;
|
|
}
|
|
|
|
if (agentName) {
|
|
configState.setAgent(agentName);
|
|
}
|
|
} catch {
|
|
// ignored
|
|
}
|
|
},
|
|
|
|
overrideNewSessionDraftTarget: (options) => {
|
|
const projectsState = useProjectsStore.getState();
|
|
const projects = projectsState.projects;
|
|
const availableWorktreesByProject = get().availableWorktreesByProject;
|
|
const explicitDirectory = normalizePath(options?.directoryOverride ?? null);
|
|
const explicitProject = options?.projectId
|
|
? projects.find((project) => project.id === options.projectId) ?? null
|
|
: null;
|
|
const inferredProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory);
|
|
const fallbackProject = explicitProject ?? inferredProject ?? projectsState.getActiveProject() ?? projects[0] ?? null;
|
|
const selectedProject = explicitProject ?? inferredProject ?? fallbackProject;
|
|
const nextDirectory = explicitDirectory ?? normalizePath(selectedProject?.path ?? null);
|
|
|
|
persistDraftTarget({
|
|
projectId: selectedProject?.id ?? null,
|
|
directory: nextDirectory,
|
|
});
|
|
|
|
set((state) => {
|
|
const previousDraft = state.newSessionDraft;
|
|
const hasPendingWorktreeRequestId = Object.prototype.hasOwnProperty.call(options, 'pendingWorktreeRequestId');
|
|
const hasBootstrapPendingDirectory = Object.prototype.hasOwnProperty.call(options, 'bootstrapPendingDirectory');
|
|
return {
|
|
newSessionDraft: {
|
|
...previousDraft,
|
|
open: true,
|
|
selectedProjectId: selectedProject?.id ?? null,
|
|
directoryOverride: nextDirectory,
|
|
pendingWorktreeRequestId: hasPendingWorktreeRequestId
|
|
? (options.pendingWorktreeRequestId ?? null)
|
|
: (previousDraft.pendingWorktreeRequestId ?? null),
|
|
bootstrapPendingDirectory: hasBootstrapPendingDirectory
|
|
? normalizePath(options.bootstrapPendingDirectory ?? null)
|
|
: (previousDraft.bootstrapPendingDirectory ?? null),
|
|
preserveDirectoryOverride: options?.preserveDirectoryOverride === true,
|
|
title: options?.title ?? previousDraft.title,
|
|
initialPrompt: options?.initialPrompt ?? previousDraft.initialPrompt,
|
|
},
|
|
currentSessionId: null,
|
|
error: null,
|
|
...(options?.initialPrompt ? { pendingInputText: options.initialPrompt, pendingInputMode: 'replace' as const } : {}),
|
|
};
|
|
});
|
|
},
|
|
|
|
setNewSessionDraftTarget: ({ projectId, directoryOverride }, options) => {
|
|
const projects = useProjectsStore.getState().projects;
|
|
const project = projectId
|
|
? projects.find((entry) => entry.id === projectId) ?? null
|
|
: null;
|
|
const normalizedDirectory = normalizePath(directoryOverride);
|
|
const normalizedProjectPath = normalizePath(project?.path ?? null);
|
|
const nextDirectory = normalizedDirectory ?? normalizedProjectPath ?? null;
|
|
|
|
let didUpdate = false;
|
|
set((state) => {
|
|
if (!state.newSessionDraft?.open) {
|
|
return state;
|
|
}
|
|
if (
|
|
options?.force !== true
|
|
&& (
|
|
state.newSessionDraft.pendingWorktreeRequestId
|
|
|| state.newSessionDraft.bootstrapPendingDirectory
|
|
|| state.newSessionDraft.preserveDirectoryOverride
|
|
)
|
|
) {
|
|
return state;
|
|
}
|
|
didUpdate = true;
|
|
return {
|
|
newSessionDraft: {
|
|
...state.newSessionDraft,
|
|
selectedProjectId: project?.id ?? null,
|
|
directoryOverride: nextDirectory,
|
|
pendingWorktreeRequestId: null,
|
|
bootstrapPendingDirectory: null,
|
|
preserveDirectoryOverride: false,
|
|
parentID: null,
|
|
},
|
|
};
|
|
});
|
|
|
|
if (didUpdate) {
|
|
persistDraftTarget({
|
|
projectId: project?.id ?? null,
|
|
directory: nextDirectory,
|
|
});
|
|
}
|
|
},
|
|
|
|
closeNewSessionDraft: () => {
|
|
const realCurrentSessionId = useSessionManagementStore.getState().currentSessionId;
|
|
set({
|
|
newSessionDraft: { open: false, selectedProjectId: null, directoryOverride: null, pendingWorktreeRequestId: null, bootstrapPendingDirectory: null, preserveDirectoryOverride: false, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined, targetFolderId: undefined },
|
|
currentSessionId: realCurrentSessionId,
|
|
});
|
|
},
|
|
|
|
setPendingDraftWorktreeRequest: (requestId) => {
|
|
set((state) => {
|
|
if (!state.newSessionDraft?.open) {
|
|
return state;
|
|
}
|
|
return {
|
|
newSessionDraft: {
|
|
...state.newSessionDraft,
|
|
pendingWorktreeRequestId: requestId,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
resolvePendingDraftWorktreeTarget: (requestId, directory, options) => {
|
|
set((state) => {
|
|
if (!state.newSessionDraft?.open || state.newSessionDraft.pendingWorktreeRequestId !== requestId) {
|
|
return state;
|
|
}
|
|
return {
|
|
newSessionDraft: {
|
|
...state.newSessionDraft,
|
|
selectedProjectId: options?.projectId ?? state.newSessionDraft.selectedProjectId ?? null,
|
|
directoryOverride: normalizePath(directory),
|
|
pendingWorktreeRequestId: null,
|
|
bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? state.newSessionDraft.bootstrapPendingDirectory ?? null),
|
|
preserveDirectoryOverride: options?.preserveDirectoryOverride ?? true,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setDraftBootstrapPendingDirectory: (directory) => {
|
|
set((state) => {
|
|
if (!state.newSessionDraft?.open) {
|
|
return state;
|
|
}
|
|
return {
|
|
newSessionDraft: {
|
|
...state.newSessionDraft,
|
|
bootstrapPendingDirectory: normalizePath(directory),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setDraftPreserveDirectoryOverride: (value) => {
|
|
set((state) => {
|
|
if (!state.newSessionDraft?.open) {
|
|
return state;
|
|
}
|
|
return {
|
|
newSessionDraft: {
|
|
...state.newSessionDraft,
|
|
preserveDirectoryOverride: value,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => {
|
|
const draft = get().newSessionDraft;
|
|
const targetFolderId = draft.targetFolderId;
|
|
get().closeNewSessionDraft();
|
|
|
|
const result = await useSessionManagementStore.getState().createSession(title, directoryOverride, parentID);
|
|
|
|
if (result?.id) {
|
|
await get().setCurrentSession(result.id);
|
|
const finalScopeKey = directoryOverride || get().lastLoadedDirectory || result.directory;
|
|
if (targetFolderId && finalScopeKey) {
|
|
useSessionFoldersStore.getState().addSessionToFolder(finalScopeKey, targetFolderId, result.id);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
createSessionFromAssistantMessage: async (sourceMessageId: string) => {
|
|
if (!sourceMessageId) {
|
|
return;
|
|
}
|
|
|
|
const messageStore = useMessageStore.getState();
|
|
const { messages, lastUsedProvider } = messageStore;
|
|
let sourceEntry: { info: Message; parts: Part[] } | undefined;
|
|
let sourceSessionId: string | undefined;
|
|
|
|
messages.forEach((messageList, sessionId) => {
|
|
const found = messageList.find((entry) => entry.info?.id === sourceMessageId);
|
|
if (found && !sourceEntry) {
|
|
sourceEntry = found;
|
|
sourceSessionId = sessionId;
|
|
}
|
|
});
|
|
|
|
if (!sourceEntry || sourceEntry.info.role !== "assistant") {
|
|
return;
|
|
}
|
|
|
|
const assistantPlanText = flattenAssistantTextParts(sourceEntry.parts);
|
|
if (!assistantPlanText.trim()) {
|
|
return;
|
|
}
|
|
|
|
const sessionManagementStore = useSessionManagementStore.getState();
|
|
const directory = resolveSessionDirectory(
|
|
sessionManagementStore.sessions,
|
|
sourceSessionId ?? null,
|
|
sessionManagementStore.getWorktreeMetadata,
|
|
);
|
|
|
|
const session = await get().createSession(undefined, directory ?? null, null);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
const { currentProviderId, currentModelId, currentAgentName } = useConfigStore.getState();
|
|
const providerID = currentProviderId || lastUsedProvider?.providerID;
|
|
const modelID = currentModelId || lastUsedProvider?.modelID;
|
|
|
|
if (!providerID || !modelID) {
|
|
return;
|
|
}
|
|
|
|
await opencodeClient.sendMessage({
|
|
id: session.id,
|
|
providerID,
|
|
modelID,
|
|
text: assistantPlanText,
|
|
prefaceText: EXECUTION_FORK_META_TEXT,
|
|
agent: currentAgentName ?? undefined,
|
|
});
|
|
},
|
|
deleteSession: (id: string, options) => useSessionManagementStore.getState().deleteSession(id, options),
|
|
deleteSessions: (ids: string[], options) => useSessionManagementStore.getState().deleteSessions(ids, options),
|
|
archiveSession: (id: string) => useSessionManagementStore.getState().archiveSession(id),
|
|
archiveSessions: (ids: string[], options) => useSessionManagementStore.getState().archiveSessions(ids, options),
|
|
updateSessionTitle: (id: string, title: string) => useSessionManagementStore.getState().updateSessionTitle(id, title),
|
|
shareSession: (id: string) => useSessionManagementStore.getState().shareSession(id),
|
|
unshareSession: (id: string) => useSessionManagementStore.getState().unshareSession(id),
|
|
setCurrentSession: async (id: string | null) => {
|
|
if (id) {
|
|
get().closeNewSessionDraft();
|
|
}
|
|
|
|
const previousSessionId = useSessionManagementStore.getState().currentSessionId;
|
|
|
|
const sessionDirectory = resolveSessionDirectory(
|
|
useSessionManagementStore.getState().sessions,
|
|
id,
|
|
useSessionManagementStore.getState().getWorktreeMetadata
|
|
);
|
|
const fallbackDirectory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory ?? null;
|
|
const resolvedDirectory = sessionDirectory ?? fallbackDirectory;
|
|
|
|
try {
|
|
opencodeClient.setDirectory(resolvedDirectory ?? undefined);
|
|
} catch (error) {
|
|
console.warn("Failed to set OpenCode directory for session switch:", error);
|
|
}
|
|
|
|
if (previousSessionId && previousSessionId !== id) {
|
|
const memoryState = get().sessionMemoryState.get(previousSessionId);
|
|
if (!memoryState?.isStreaming) {
|
|
|
|
const previousMessages = get().messages.get(previousSessionId) || [];
|
|
if (previousMessages.length > 0) {
|
|
get().updateViewportAnchor(previousSessionId, previousMessages.length - 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
useSessionManagementStore.getState().setCurrentSession(id);
|
|
|
|
if (id) {
|
|
|
|
const existingMessages = get().messages.get(id);
|
|
const historyMeta = get().sessionHistoryMeta.get(id);
|
|
const needsHistoryBootstrap =
|
|
!historyMeta ||
|
|
typeof historyMeta.complete !== 'boolean';
|
|
|
|
if (!existingMessages || needsHistoryBootstrap) {
|
|
|
|
await get().loadMessages(id);
|
|
}
|
|
|
|
// Analyze session messages to extract agent/model/variant choices
|
|
// This ensures context is available even when ModelControls isn't mounted
|
|
const sessionMessages = get().messages.get(id);
|
|
if (sessionMessages && sessionMessages.length > 0) {
|
|
const agents = useConfigStore.getState().agents;
|
|
if (agents.length > 0) {
|
|
const analysisSignature = buildSessionChoiceAnalysisSignature(sessionMessages);
|
|
if (sessionChoiceAnalysisSignature.get(id) === analysisSignature) {
|
|
return;
|
|
}
|
|
try {
|
|
await useContextStore.getState().analyzeAndSaveExternalSessionChoices(
|
|
id,
|
|
agents,
|
|
get().messages
|
|
);
|
|
sessionChoiceAnalysisSignature.set(id, analysisSignature);
|
|
} catch (error) {
|
|
console.warn('Failed to analyze session choices:', error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
},
|
|
loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit),
|
|
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' = 'normal') => {
|
|
const draft = get().newSessionDraft;
|
|
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
|
|
|
|
const setStatus = (sessionId: string, type: 'idle' | 'busy') => {
|
|
set((state) => {
|
|
const next = new Map(state.sessionStatus ?? new Map());
|
|
next.set(sessionId, { type });
|
|
return { sessionStatus: next };
|
|
});
|
|
};
|
|
|
|
if (draft?.open) {
|
|
const draftTargetFolderId = draft.targetFolderId;
|
|
let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null;
|
|
const draftProjectId = draft.selectedProjectId ?? null;
|
|
|
|
if (draft.pendingWorktreeRequestId) {
|
|
draftDirectoryOverride = await waitForPendingDraftWorktreeRequest(draft.pendingWorktreeRequestId);
|
|
get().resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride);
|
|
}
|
|
|
|
const created = await useSessionManagementStore
|
|
.getState()
|
|
.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 configState = useConfigStore.getState();
|
|
const draftAgentName = configState.currentAgentName;
|
|
const effectiveDraftAgent = trimmedAgent ?? draftAgentName;
|
|
const draftProviderId = configState.currentProviderId;
|
|
const draftModelId = configState.currentModelId;
|
|
|
|
if (draftProviderId && draftModelId) {
|
|
try {
|
|
useContextStore.getState().saveSessionModelSelection(created.id, draftProviderId, draftModelId);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}
|
|
|
|
if (effectiveDraftAgent) {
|
|
try {
|
|
useContextStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
|
|
if (draftProviderId && draftModelId) {
|
|
try {
|
|
useContextStore
|
|
.getState()
|
|
.saveAgentModelForSession(created.id, effectiveDraftAgent, draftProviderId, draftModelId);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
|
|
try {
|
|
useContextStore
|
|
.getState()
|
|
.saveAgentModelVariantForSession(created.id, effectiveDraftAgent, draftProviderId, draftModelId, variant);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
useSessionManagementStore
|
|
.getState()
|
|
.initializeNewOpenChamberSession(created.id, configState.agents);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
|
|
// Capture synthetic parts before clearing draft
|
|
const draftSyntheticParts = draft.syntheticParts;
|
|
|
|
get().closeNewSessionDraft();
|
|
await get().setCurrentSession(created.id);
|
|
|
|
// Assign to target folder if session was created from folder's + button
|
|
if (draftTargetFolderId) {
|
|
const scopeKey = draftDirectoryOverride || created.directory || null;
|
|
if (scopeKey) {
|
|
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, draftTargetFolderId, created.id);
|
|
}
|
|
}
|
|
|
|
setStatus(created.id, 'busy');
|
|
|
|
// Merge draft synthetic parts with any additional parts passed to sendMessage
|
|
const mergedAdditionalParts = draftSyntheticParts?.length
|
|
? [...(additionalParts || []), ...draftSyntheticParts]
|
|
: additionalParts;
|
|
|
|
const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null);
|
|
if (createdDirectory) {
|
|
await waitForWorktreeBootstrap(createdDirectory);
|
|
}
|
|
|
|
try {
|
|
markPendingUserSendAnimation(created.id);
|
|
return await useMessageStore
|
|
.getState()
|
|
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant, inputMode);
|
|
} catch (error) {
|
|
setStatus(created.id, 'idle');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
|
|
const sessionAgentSelection = currentSessionId
|
|
? useContextStore.getState().getSessionAgentSelection(currentSessionId)
|
|
: null;
|
|
const configAgentName = useConfigStore.getState().currentAgentName;
|
|
const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined;
|
|
|
|
if (currentSessionId && effectiveAgent) {
|
|
try {
|
|
useContextStore.getState().saveSessionAgentSelection(currentSessionId, effectiveAgent);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
|
|
try {
|
|
useContextStore
|
|
.getState()
|
|
.saveAgentModelVariantForSession(currentSessionId, effectiveAgent, providerID, modelID, variant);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}
|
|
|
|
if (currentSessionId) {
|
|
setStatus(currentSessionId, 'busy');
|
|
|
|
const memoryState = get().sessionMemoryState.get(currentSessionId);
|
|
if (!memoryState || !memoryState.lastUserMessageAt) {
|
|
const currentMemoryState = get().sessionMemoryState;
|
|
const newMemoryState = new Map(currentMemoryState);
|
|
newMemoryState.set(currentSessionId, {
|
|
viewportAnchor: memoryState?.viewportAnchor ?? 0,
|
|
isStreaming: memoryState?.isStreaming ?? false,
|
|
lastAccessedAt: Date.now(),
|
|
backgroundMessageCount: memoryState?.backgroundMessageCount ?? 0,
|
|
lastUserMessageAt: Date.now(),
|
|
});
|
|
set({ sessionMemoryState: newMemoryState });
|
|
}
|
|
}
|
|
|
|
const currentSessionDirectory = currentSessionId
|
|
? normalizePath(useSessionManagementStore.getState().getDirectoryForSession(currentSessionId))
|
|
: null;
|
|
if (currentSessionDirectory) {
|
|
await waitForWorktreeBootstrap(currentSessionDirectory);
|
|
}
|
|
|
|
// Notify server that user sent a message in this session
|
|
if (currentSessionId) {
|
|
fetch(`/api/sessions/${currentSessionId}/message-sent`, { method: 'POST' })
|
|
.catch(() => { /* ignore */ });
|
|
}
|
|
|
|
try {
|
|
if (currentSessionId) {
|
|
markPendingUserSendAnimation(currentSessionId);
|
|
}
|
|
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant, inputMode);
|
|
} catch (error) {
|
|
if (currentSessionId) {
|
|
setStatus(currentSessionId, 'idle');
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
abortCurrentOperation: (sessionIdOverride?: string) => {
|
|
const sessionId = sessionIdOverride || useSessionManagementStore.getState().currentSessionId;
|
|
return useMessageStore.getState().abortCurrentOperation(sessionId || undefined);
|
|
},
|
|
armAbortPrompt: (durationMs = 3000) => {
|
|
const sessionId = useSessionManagementStore.getState().currentSessionId;
|
|
if (!sessionId) {
|
|
return null;
|
|
}
|
|
const expiresAt = Date.now() + durationMs;
|
|
set({ abortPromptSessionId: sessionId, abortPromptExpiresAt: expiresAt });
|
|
return expiresAt;
|
|
},
|
|
clearAbortPrompt: () => {
|
|
set({ abortPromptSessionId: null, abortPromptExpiresAt: null });
|
|
},
|
|
acknowledgeSessionAbort: (sessionId: string) => {
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
useMessageStore.getState().acknowledgeSessionAbort(sessionId);
|
|
},
|
|
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string) => {
|
|
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
|
|
|
|
const effectiveCurrent = currentSessionId || sessionId;
|
|
return useMessageStore.getState().addStreamingPart(sessionId, messageId, part, role, effectiveCurrent);
|
|
},
|
|
applyPartDelta: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string) => {
|
|
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
|
|
const effectiveCurrent = currentSessionId || sessionId;
|
|
return useMessageStore.getState().applyPartDelta(sessionId, messageId, partId, field, delta, role, effectiveCurrent);
|
|
},
|
|
completeStreamingMessage: (sessionId: string, messageId: string) => useMessageStore.getState().completeStreamingMessage(sessionId, messageId),
|
|
markMessageStreamSettled: (messageId: string) => useMessageStore.getState().markMessageStreamSettled(messageId),
|
|
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record<string, unknown>) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo),
|
|
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null),
|
|
addPermission: (permission: PermissionRequest) => {
|
|
return usePermissionStore.getState().addPermission(permission);
|
|
},
|
|
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, requestId, response),
|
|
dismissPermission: (sessionId: string, requestId: string) => usePermissionStore.getState().dismissPermission(sessionId, requestId),
|
|
|
|
addQuestion: (question: QuestionRequest) => useQuestionStore.getState().addQuestion(question),
|
|
dismissQuestion: (sessionId: string, requestId: string) => useQuestionStore.getState().dismissQuestion(sessionId, requestId),
|
|
respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => useQuestionStore.getState().respondToQuestion(sessionId, requestId, answers),
|
|
rejectQuestion: (sessionId: string, requestId: string) => useQuestionStore.getState().rejectQuestion(sessionId, requestId),
|
|
|
|
clearError: () => useSessionManagementStore.getState().clearError(),
|
|
getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory),
|
|
getDirectoryForSession: (sessionId: string) => useSessionManagementStore.getState().getDirectoryForSession(sessionId),
|
|
getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId),
|
|
getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId),
|
|
syncMessages: (
|
|
sessionId: string,
|
|
messages: { info: Message; parts: Part[] }[],
|
|
options?: { replace?: boolean }
|
|
) => useMessageStore.getState().syncMessages(sessionId, messages, options),
|
|
applySessionMetadata: (sessionId: string, metadata: Partial<Session>) => useSessionManagementStore.getState().applySessionMetadata(sessionId, metadata),
|
|
|
|
addAttachedFile: (file: File) => useFileStore.getState().addAttachedFile(file),
|
|
addServerFile: (path: string, name: string, content?: string) => useFileStore.getState().addServerFile(path, name, content),
|
|
removeAttachedFile: (id: string) => useFileStore.getState().removeAttachedFile(id),
|
|
clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(),
|
|
|
|
updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor),
|
|
loadMoreMessages: (sessionId: string, direction: "up" | "down") => useMessageStore.getState().loadMoreMessages(sessionId, direction),
|
|
|
|
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => useContextStore.getState().saveSessionModelSelection(sessionId, providerId, modelId),
|
|
getSessionModelSelection: (sessionId: string) => useContextStore.getState().getSessionModelSelection(sessionId),
|
|
saveSessionAgentSelection: (sessionId: string, agentName: string) => useContextStore.getState().saveSessionAgentSelection(sessionId, agentName),
|
|
getSessionAgentSelection: (sessionId: string) => useContextStore.getState().getSessionAgentSelection(sessionId),
|
|
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerId, modelId),
|
|
getAgentModelForSession: (sessionId: string, agentName: string) => useContextStore.getState().getAgentModelForSession(sessionId, agentName),
|
|
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, variant),
|
|
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => useContextStore.getState().getAgentModelVariantForSession(sessionId, agentName, providerId, modelId),
|
|
analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Record<string, unknown>[]) => {
|
|
const messages = useMessageStore.getState().messages;
|
|
return useContextStore.getState().analyzeAndSaveExternalSessionChoices(sessionId, agents, messages);
|
|
},
|
|
isOpenChamberCreatedSession: (sessionId: string) => useSessionManagementStore.getState().isOpenChamberCreatedSession(sessionId),
|
|
markSessionAsOpenChamberCreated: (sessionId: string) => useSessionManagementStore.getState().markSessionAsOpenChamberCreated(sessionId),
|
|
initializeNewOpenChamberSession: (sessionId: string, agents: Record<string, unknown>[]) => useSessionManagementStore.getState().initializeNewOpenChamberSession(sessionId, agents),
|
|
setWorktreeMetadata: (sessionId: string, metadata) => useSessionManagementStore.getState().setWorktreeMetadata(sessionId, metadata),
|
|
setSessionDirectory: (sessionId: string, directory: string | null) => useSessionManagementStore.getState().setSessionDirectory(sessionId, directory),
|
|
getWorktreeMetadata: (sessionId: string) => useSessionManagementStore.getState().getWorktreeMetadata(sessionId),
|
|
getContextUsage: (contextLimit: number, outputLimit: number) => {
|
|
if (get().newSessionDraft?.open) {
|
|
return null;
|
|
}
|
|
|
|
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
|
|
if (!currentSessionId) return null;
|
|
const messages = useMessageStore.getState().messages;
|
|
return useContextStore.getState().getContextUsage(currentSessionId, contextLimit, outputLimit, messages);
|
|
},
|
|
updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => {
|
|
const messages = useMessageStore.getState().messages;
|
|
return useContextStore.getState().updateSessionContextUsage(sessionId, contextLimit, outputLimit, messages);
|
|
},
|
|
initializeSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => {
|
|
const messages = useMessageStore.getState().messages;
|
|
return useContextStore.getState().initializeSessionContextUsage(sessionId, contextLimit, outputLimit, messages);
|
|
},
|
|
debugSessionMessages: async (sessionId: string) => {
|
|
const messages = normalizeMessageRecordsForProjection(
|
|
useMessageStore.getState().messages.get(sessionId) || []
|
|
);
|
|
const session = useSessionManagementStore.getState().sessions.find(s => s.id === sessionId);
|
|
console.log(`Debug session ${sessionId}:`, {
|
|
session,
|
|
messageCount: messages.length,
|
|
messages: messages.map(m => ({
|
|
id: m.info.id,
|
|
role: m.info.role,
|
|
parts: m.parts.length,
|
|
tokens: (m.info as Record<string, unknown>).tokens
|
|
}))
|
|
});
|
|
},
|
|
pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => {
|
|
const messages = useMessageStore.getState().messages;
|
|
return useContextStore.getState().pollForTokenUpdates(sessionId, messageId, messages, maxAttempts);
|
|
},
|
|
updateSession: (session: Session) => useSessionManagementStore.getState().updateSession(session),
|
|
removeSessionFromStore: (sessionId: string) => useSessionManagementStore.getState().removeSessionFromStore(sessionId),
|
|
|
|
revertToMessage: async (sessionId: string, messageId: string) => {
|
|
// Get the message text before reverting
|
|
const messages = useMessageStore.getState().messages.get(sessionId) || [];
|
|
const targetMessage = messages.find((m) => m.info.id === messageId);
|
|
let messageText = '';
|
|
|
|
if (targetMessage && targetMessage.info.role === 'user') {
|
|
// Extract text from user message parts
|
|
const textParts = targetMessage.parts.filter((p) => p.type === 'text');
|
|
messageText = textParts
|
|
.map((p) => {
|
|
const part = p as { text?: string; content?: string };
|
|
return part.text || part.content || '';
|
|
})
|
|
.join('\n')
|
|
.trim();
|
|
}
|
|
|
|
// Call revert API
|
|
const updatedSession = await opencodeClient.revertSession(sessionId, messageId);
|
|
|
|
// Update session in store (this stores the revert.messageID)
|
|
useSessionManagementStore.getState().updateSession(updatedSession);
|
|
|
|
// Filter out reverted messages from the store
|
|
// Messages with ID >= revert.messageID should be removed
|
|
const currentMessages = useMessageStore.getState().messages.get(sessionId) || [];
|
|
const revertMessageId = updatedSession.revert?.messageID;
|
|
|
|
if (revertMessageId) {
|
|
// Keep only messages before the revert point.
|
|
// Fallback to the originally clicked message if SDK returns an id
|
|
// that is not loaded in the current in-memory window.
|
|
let revertIndex = currentMessages.findIndex((m) => m.info.id === revertMessageId);
|
|
if (revertIndex === -1) {
|
|
revertIndex = currentMessages.findIndex((m) => m.info.id === messageId);
|
|
}
|
|
|
|
if (revertIndex !== -1) {
|
|
const filteredMessages = currentMessages.slice(0, revertIndex);
|
|
useMessageStore.getState().syncMessages(sessionId, filteredMessages, { replace: true });
|
|
}
|
|
}
|
|
|
|
// Set pending input text for ChatInput to consume
|
|
if (messageText) {
|
|
set({ pendingInputText: messageText, pendingInputMode: 'replace' });
|
|
}
|
|
},
|
|
|
|
handleSlashUndo: async (sessionId: string) => {
|
|
const messages = get().messages.get(sessionId) || [];
|
|
const userMessages = messages.filter(m => m.info.role === 'user');
|
|
const sessions = get().sessions;
|
|
const currentSession = sessions.find(s => s.id === sessionId);
|
|
|
|
// No-op when there is nothing to undo/redo
|
|
if (userMessages.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Get current revert state to determine which message to undo next
|
|
const revertToId = currentSession?.revert?.messageID;
|
|
|
|
// Find the user message AFTER the revert point (or last message if no revert)
|
|
let targetMessage;
|
|
if (revertToId) {
|
|
const revertIndex = userMessages.findIndex(m => m.info.id === revertToId);
|
|
targetMessage = userMessages[revertIndex + 1];
|
|
} else {
|
|
targetMessage = userMessages[userMessages.length - 1];
|
|
}
|
|
|
|
// No-op when there is nothing to undo/redo
|
|
if (!targetMessage) {
|
|
return;
|
|
}
|
|
|
|
// Helper to extract text preview
|
|
const textPart = targetMessage.parts.find(p => p.type === 'text');
|
|
const preview = typeof textPart === 'object' && textPart && 'text' in textPart
|
|
? String(textPart.text).slice(0, 50) + (String(textPart.text).length > 50 ? '...' : '')
|
|
: '[No text]';
|
|
|
|
await get().revertToMessage(sessionId, targetMessage.info.id);
|
|
|
|
const { toast } = await import('sonner');
|
|
toast.success(`Undid to: ${preview}`);
|
|
},
|
|
|
|
handleSlashRedo: async (sessionId: string) => {
|
|
const sessions = get().sessions;
|
|
const currentSession = sessions.find(s => s.id === sessionId);
|
|
const revertToId = currentSession?.revert?.messageID;
|
|
|
|
// No-op when there is nothing to undo/redo
|
|
if (!revertToId) {
|
|
return;
|
|
}
|
|
|
|
const messages = get().messages.get(sessionId) || [];
|
|
const userMessages = messages.filter(m => m.info.role === 'user');
|
|
|
|
// Find the user message BEFORE the revert point
|
|
const revertIndex = userMessages.findIndex(m => m.info.id === revertToId);
|
|
const targetMessage = userMessages[revertIndex - 1];
|
|
|
|
if (targetMessage) {
|
|
// Partial redo: move to previous message
|
|
const textPart = targetMessage.parts.find(p => p.type === 'text');
|
|
const preview = typeof textPart === 'object' && textPart && 'text' in textPart
|
|
? String(textPart.text).slice(0, 50) + (String(textPart.text).length > 50 ? '...' : '')
|
|
: '[No text]';
|
|
|
|
await get().revertToMessage(sessionId, targetMessage.info.id);
|
|
|
|
const { toast } = await import('sonner');
|
|
toast.success(`Redid to: ${preview}`);
|
|
} else {
|
|
// Full unrevert: restore all
|
|
const session = await opencodeClient.unrevertSession(sessionId);
|
|
await useSessionManagementStore.getState().updateSession(session);
|
|
await get().loadMessages(sessionId);
|
|
|
|
const { toast } = await import('sonner');
|
|
toast.success('Restored all messages');
|
|
}
|
|
},
|
|
|
|
forkFromMessage: async (sessionId: string, messageId: string) => {
|
|
const sessions = get().sessions;
|
|
const existingSession = sessions.find(s => s.id === sessionId);
|
|
if (!existingSession) return;
|
|
|
|
try {
|
|
// 1. Call SDK fork - backend copies all messages up to messageId
|
|
const result = await opencodeClient.forkSession(sessionId, messageId);
|
|
|
|
if (!result || !result.id) {
|
|
const { toast } = await import('sonner');
|
|
toast.error('Failed to fork session');
|
|
return;
|
|
}
|
|
|
|
// 2. Extract fork point content for input field (text + file attachments)
|
|
const messages = get().messages.get(sessionId) || [];
|
|
const message = messages.find(m => m.info.id === messageId);
|
|
|
|
if (!message) {
|
|
const { toast } = await import('sonner');
|
|
toast.error('Message not found');
|
|
return;
|
|
}
|
|
|
|
// Extract text content from non-synthetic, non-ignored text parts
|
|
let inputText = '';
|
|
for (const part of message.parts) {
|
|
if (part.type === 'text' && !part.synthetic && !part.ignored) {
|
|
const typedPart = part as { text?: string };
|
|
inputText += typedPart.text || '';
|
|
}
|
|
}
|
|
|
|
// 3. Switch to new session
|
|
get().setCurrentSession(result.id);
|
|
|
|
// 4. Show fork point as pending input (will populate ChatInput)
|
|
if (inputText) {
|
|
set({ pendingInputText: inputText, pendingInputMode: 'replace' });
|
|
}
|
|
|
|
// Load the new session's messages
|
|
await get().loadMessages(result.id);
|
|
|
|
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');
|
|
}
|
|
},
|
|
|
|
setPendingInputText: (text: string | null, mode: 'replace' | 'append' | 'append-inline' = 'replace') => {
|
|
set({ pendingInputText: text, pendingInputMode: mode });
|
|
},
|
|
|
|
consumePendingInputText: () => {
|
|
const text = get().pendingInputText;
|
|
const mode = get().pendingInputMode;
|
|
if (text !== null) {
|
|
set({ pendingInputText: null, pendingInputMode: 'replace' });
|
|
}
|
|
if (text === null) {
|
|
return null;
|
|
}
|
|
return { text, mode };
|
|
},
|
|
|
|
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => {
|
|
set({ pendingSyntheticParts: parts });
|
|
},
|
|
|
|
consumePendingSyntheticParts: () => {
|
|
const parts = get().pendingSyntheticParts;
|
|
if (parts !== null) {
|
|
set({ pendingSyntheticParts: null });
|
|
}
|
|
return parts;
|
|
},
|
|
}),
|
|
{
|
|
name: "composed-session-store",
|
|
}
|
|
),
|
|
);
|
|
|
|
// rAF debounce IDs for useMessageStore -> useSessionStore sync
|
|
let messageStoreSyncRafId: number | null = null;
|
|
let userSummaryTitlesRafId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
useSessionManagementStore.subscribe((state, prevState) => {
|
|
|
|
if (
|
|
state.sessions === prevState.sessions &&
|
|
state.archivedSessions === prevState.archivedSessions &&
|
|
state.sessionsByDirectory === prevState.sessionsByDirectory &&
|
|
state.currentSessionId === prevState.currentSessionId &&
|
|
state.lastLoadedDirectory === prevState.lastLoadedDirectory &&
|
|
state.isLoading === prevState.isLoading &&
|
|
state.error === prevState.error &&
|
|
state.webUICreatedSessions === prevState.webUICreatedSessions &&
|
|
state.worktreeMetadata === prevState.worktreeMetadata &&
|
|
state.availableWorktrees === prevState.availableWorktrees &&
|
|
state.availableWorktreesByProject === prevState.availableWorktreesByProject
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const draftOpen = useSessionStore.getState().newSessionDraft?.open;
|
|
|
|
useSessionStore.setState({
|
|
sessions: state.sessions,
|
|
archivedSessions: state.archivedSessions,
|
|
sessionsByDirectory: state.sessionsByDirectory,
|
|
currentSessionId: draftOpen ? null : state.currentSessionId,
|
|
lastLoadedDirectory: state.lastLoadedDirectory,
|
|
isLoading: state.isLoading,
|
|
error: state.error,
|
|
webUICreatedSessions: state.webUICreatedSessions,
|
|
worktreeMetadata: state.worktreeMetadata,
|
|
availableWorktrees: state.availableWorktrees,
|
|
availableWorktreesByProject: state.availableWorktreesByProject,
|
|
});
|
|
});
|
|
|
|
useMessageStore.subscribe((state, prevState) => {
|
|
// Early-return equality check stays outside the rAF so we skip scheduling
|
|
// entirely when nothing relevant changed.
|
|
if (
|
|
state.messages === prevState.messages &&
|
|
state.sessionMemoryState === prevState.sessionMemoryState &&
|
|
state.sessionHistoryMeta === prevState.sessionHistoryMeta &&
|
|
state.messageStreamStates === prevState.messageStreamStates &&
|
|
state.sessionCompactionUntil === prevState.sessionCompactionUntil &&
|
|
state.sessionAbortFlags === prevState.sessionAbortFlags &&
|
|
state.streamingMessageIds === prevState.streamingMessageIds &&
|
|
state.abortControllers === prevState.abortControllers &&
|
|
state.lastUsedProvider === prevState.lastUsedProvider &&
|
|
state.isSyncing === prevState.isSyncing
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Debounce the expensive sessionStore update to at most once per animation
|
|
// frame. Multiple messageStore updates within the same frame (e.g. several
|
|
// SSE tokens arriving before the next paint) collapse into a single setState.
|
|
if (messageStoreSyncRafId !== null) {
|
|
cancelAnimationFrame(messageStoreSyncRafId);
|
|
}
|
|
messageStoreSyncRafId = requestAnimationFrame(() => {
|
|
messageStoreSyncRafId = null;
|
|
|
|
// Read the LATEST state at flush time, not the stale state captured by
|
|
// the subscription closure.
|
|
const latest = useMessageStore.getState();
|
|
|
|
useSessionStore.setState({
|
|
messages: latest.messages,
|
|
sessionMemoryState: latest.sessionMemoryState,
|
|
sessionHistoryMeta: latest.sessionHistoryMeta,
|
|
messageStreamStates: latest.messageStreamStates,
|
|
sessionCompactionUntil: latest.sessionCompactionUntil,
|
|
sessionAbortFlags: latest.sessionAbortFlags,
|
|
streamingMessageIds: latest.streamingMessageIds,
|
|
abortControllers: latest.abortControllers,
|
|
lastUsedProvider: latest.lastUsedProvider,
|
|
isSyncing: latest.isSyncing,
|
|
});
|
|
|
|
// Sidebar titles don't need real-time updates; debounce separately at
|
|
// 500 ms so the expensive per-message iteration doesn't happen every
|
|
// frame during streaming.
|
|
if (userSummaryTitlesRafId !== null) {
|
|
clearTimeout(userSummaryTitlesRafId);
|
|
}
|
|
userSummaryTitlesRafId = setTimeout(() => {
|
|
userSummaryTitlesRafId = null;
|
|
const titleState = useMessageStore.getState();
|
|
const userSummaryTitles = new Map<string, { title: string; createdAt: number | null }>();
|
|
titleState.messages.forEach((messageList, sessionId) => {
|
|
if (!Array.isArray(messageList) || messageList.length === 0) {
|
|
return;
|
|
}
|
|
for (let index = messageList.length - 1; index >= 0; index -= 1) {
|
|
const entry = messageList[index];
|
|
if (!entry || !entry.info) {
|
|
continue;
|
|
}
|
|
const info = entry.info as Message & {
|
|
summary?: { title?: string | null } | null;
|
|
time?: { created?: number | null };
|
|
};
|
|
if (info.role === "user") {
|
|
const title = info.summary?.title;
|
|
if (typeof title === "string") {
|
|
const trimmed = title.trim();
|
|
if (trimmed.length > 0) {
|
|
const createdAt =
|
|
info.time && typeof info.time.created === "number"
|
|
? info.time.created
|
|
: null;
|
|
userSummaryTitles.set(sessionId, { title: trimmed, createdAt });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
useSessionStore.setState({ userSummaryTitles });
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
useFileStore.subscribe((state, prevState) => {
|
|
if (state.attachedFiles === prevState.attachedFiles) {
|
|
return;
|
|
}
|
|
|
|
useSessionStore.setState({
|
|
attachedFiles: state.attachedFiles,
|
|
});
|
|
});
|
|
|
|
useContextStore.subscribe((state, prevState) => {
|
|
if (
|
|
state.sessionModelSelections === prevState.sessionModelSelections &&
|
|
state.sessionAgentSelections === prevState.sessionAgentSelections &&
|
|
state.sessionAgentModelSelections === prevState.sessionAgentModelSelections &&
|
|
state.currentAgentContext === prevState.currentAgentContext &&
|
|
state.sessionContextUsage === prevState.sessionContextUsage &&
|
|
state.sessionAgentEditModes === prevState.sessionAgentEditModes
|
|
) {
|
|
return;
|
|
}
|
|
|
|
useSessionStore.setState({
|
|
sessionModelSelections: state.sessionModelSelections,
|
|
sessionAgentSelections: state.sessionAgentSelections,
|
|
sessionAgentModelSelections: state.sessionAgentModelSelections,
|
|
currentAgentContext: state.currentAgentContext,
|
|
sessionContextUsage: state.sessionContextUsage,
|
|
sessionAgentEditModes: state.sessionAgentEditModes,
|
|
});
|
|
});
|
|
|
|
usePermissionStore.subscribe((state, prevState) => {
|
|
if (state.permissions === prevState.permissions) {
|
|
return;
|
|
}
|
|
|
|
useSessionStore.setState({
|
|
permissions: state.permissions,
|
|
});
|
|
});
|
|
|
|
useQuestionStore.subscribe((state, prevState) => {
|
|
if (state.questions === prevState.questions) {
|
|
return;
|
|
}
|
|
|
|
useSessionStore.setState({
|
|
questions: state.questions,
|
|
});
|
|
});
|
|
|
|
useDirectoryStore.subscribe((state, prevState) => {
|
|
const nextDirectory = normalizePath(state.currentDirectory ?? null);
|
|
const prevDirectory = normalizePath(prevState.currentDirectory ?? null);
|
|
if (nextDirectory === prevDirectory) {
|
|
return;
|
|
}
|
|
|
|
const draft = useSessionStore.getState().newSessionDraft;
|
|
if (!draft?.open) {
|
|
return;
|
|
}
|
|
|
|
if (draft.pendingWorktreeRequestId || draft.bootstrapPendingDirectory || draft.preserveDirectoryOverride) {
|
|
return;
|
|
}
|
|
|
|
const draftDirectory = normalizePath(draft.directoryOverride);
|
|
if (draftDirectory && draftDirectory !== prevDirectory) {
|
|
return;
|
|
}
|
|
|
|
const projects = useProjectsStore.getState().projects;
|
|
const resolvedProject = resolveDraftProjectForDirectory(
|
|
projects,
|
|
useSessionStore.getState().availableWorktreesByProject,
|
|
nextDirectory,
|
|
);
|
|
|
|
useSessionStore.setState((store) => ({
|
|
newSessionDraft: {
|
|
...store.newSessionDraft,
|
|
selectedProjectId: resolvedProject?.id ?? store.newSessionDraft.selectedProjectId ?? null,
|
|
directoryOverride: nextDirectory,
|
|
parentID: null,
|
|
},
|
|
}));
|
|
|
|
persistDraftTarget({
|
|
projectId: resolvedProject?.id ?? draft.selectedProjectId ?? null,
|
|
directory: nextDirectory,
|
|
});
|
|
});
|
|
|
|
const bootDraftOpen = useSessionStore.getState().newSessionDraft?.open;
|
|
|
|
useSessionStore.setState({
|
|
sessions: useSessionManagementStore.getState().sessions,
|
|
currentSessionId: bootDraftOpen ? null : useSessionManagementStore.getState().currentSessionId,
|
|
lastLoadedDirectory: useSessionManagementStore.getState().lastLoadedDirectory,
|
|
isLoading: useSessionManagementStore.getState().isLoading,
|
|
error: useSessionManagementStore.getState().error,
|
|
webUICreatedSessions: useSessionManagementStore.getState().webUICreatedSessions,
|
|
worktreeMetadata: useSessionManagementStore.getState().worktreeMetadata,
|
|
availableWorktrees: useSessionManagementStore.getState().availableWorktrees,
|
|
availableWorktreesByProject: useSessionManagementStore.getState().availableWorktreesByProject,
|
|
messages: useMessageStore.getState().messages,
|
|
sessionMemoryState: useMessageStore.getState().sessionMemoryState,
|
|
sessionHistoryMeta: useMessageStore.getState().sessionHistoryMeta,
|
|
messageStreamStates: useMessageStore.getState().messageStreamStates,
|
|
sessionCompactionUntil: useMessageStore.getState().sessionCompactionUntil,
|
|
sessionAbortFlags: useMessageStore.getState().sessionAbortFlags,
|
|
streamingMessageIds: useMessageStore.getState().streamingMessageIds,
|
|
abortControllers: useMessageStore.getState().abortControllers,
|
|
lastUsedProvider: useMessageStore.getState().lastUsedProvider,
|
|
isSyncing: useMessageStore.getState().isSyncing,
|
|
permissions: usePermissionStore.getState().permissions,
|
|
questions: useQuestionStore.getState().questions,
|
|
attachedFiles: useFileStore.getState().attachedFiles,
|
|
sessionModelSelections: useContextStore.getState().sessionModelSelections,
|
|
sessionAgentSelections: useContextStore.getState().sessionAgentSelections,
|
|
sessionAgentModelSelections: useContextStore.getState().sessionAgentModelSelections,
|
|
currentAgentContext: useContextStore.getState().currentAgentContext,
|
|
sessionContextUsage: useContextStore.getState().sessionContextUsage,
|
|
sessionAgentEditModes: useContextStore.getState().sessionAgentEditModes,
|
|
abortPromptSessionId: null,
|
|
abortPromptExpiresAt: null,
|
|
});
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.__zustand_session_store__ = useSessionStore;
|
|
}
|