Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
+515
View File
@@ -0,0 +1,515 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import type { EditPermissionMode } from "./types/sessionTypes";
import { getAgentDefaultEditPermission } from "./utils/permissionUtils";
import { extractTokensFromMessage } from "./utils/tokenUtils";
import { calculateContextUsage } from "./utils/contextUtils";
import { getSafeStorage } from "./utils/safeStorage";
interface ContextUsage {
totalTokens: number;
percentage: number;
contextLimit: number;
outputLimit?: number;
normalizedOutput?: number;
thresholdLimit: number;
lastMessageId?: string;
}
interface ContextState {
sessionModelSelections: Map<string, { providerId: string; modelId: string }>;
sessionAgentSelections: Map<string, string>;
sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>;
currentAgentContext: Map<string, string>;
sessionContextUsage: Map<string, ContextUsage>;
sessionAgentEditModes: Map<string, Map<string, EditPermissionMode>>;
hasHydrated: boolean;
}
interface ContextActions {
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void;
getSessionModelSelection: (sessionId: string) => { providerId: string; modelId: string } | null;
saveSessionAgentSelection: (sessionId: string, agentName: string) => void;
getSessionAgentSelection: (sessionId: string) => string | null;
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null;
analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: any[], messages: Map<string, { info: any; parts: any[] }[]>) => Promise<Map<string, { providerId: string; modelId: string; timestamp: number }>>;
getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => ContextUsage | null;
updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => void;
initializeSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => void;
pollForTokenUpdates: (sessionId: string, messageId: string, messages: Map<string, { info: any; parts: any[] }[]>, maxAttempts?: number) => void;
getCurrentAgent: (sessionId: string) => string | undefined;
getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => EditPermissionMode;
toggleSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => void;
setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void;
}
type ContextStore = ContextState & ContextActions;
const EDIT_PERMISSION_SEQUENCE: EditPermissionMode[] = ['ask', 'allow', 'full'];
export const useContextStore = create<ContextStore>()(
devtools(
persist(
(set, get) => ({
sessionModelSelections: new Map(),
sessionAgentSelections: new Map(),
sessionAgentModelSelections: new Map(),
currentAgentContext: new Map(),
sessionContextUsage: new Map(),
sessionAgentEditModes: new Map(),
hasHydrated: typeof window === "undefined",
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => {
set((state) => {
const newSelections = new Map(state.sessionModelSelections);
newSelections.set(sessionId, { providerId, modelId });
return { sessionModelSelections: newSelections };
});
},
getSessionModelSelection: (sessionId: string) => {
const { sessionModelSelections } = get();
return sessionModelSelections.get(sessionId) || null;
},
saveSessionAgentSelection: (sessionId: string, agentName: string) => {
set((state) => {
const newSelections = new Map(state.sessionAgentSelections);
newSelections.set(sessionId, agentName);
return { sessionAgentSelections: newSelections };
});
},
getSessionAgentSelection: (sessionId: string) => {
const { sessionAgentSelections } = get();
return sessionAgentSelections.get(sessionId) || null;
},
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => {
set((state) => {
const newSelections = new Map(state.sessionAgentModelSelections);
let agentMap = newSelections.get(sessionId);
if (!agentMap) {
agentMap = new Map();
} else {
agentMap = new Map(agentMap);
}
agentMap.set(agentName, { providerId, modelId });
newSelections.set(sessionId, agentMap);
return { sessionAgentModelSelections: newSelections };
});
},
getAgentModelForSession: (sessionId: string, agentName: string) => {
const { sessionAgentModelSelections } = get();
const agentMap = sessionAgentModelSelections.get(sessionId);
if (!agentMap) return null;
return agentMap.get(agentName) || null;
},
analyzeAndSaveExternalSessionChoices: async (sessionId: string, agents: any[], messages: Map<string, { info: any; parts: any[] }[]>) => {
const { saveAgentModelForSession } = get();
const agentLastChoices = new Map<
string,
{
providerId: string;
modelId: string;
timestamp: number;
}
>();
const extractAgentFromMessage = (messageInfo: any, messageIndex: number): string | null => {
if ("mode" in messageInfo && messageInfo.mode && typeof messageInfo.mode === "string") {
const modeAgent = agents.find((a) => a.name === messageInfo.mode);
if (modeAgent) {
return messageInfo.mode;
}
}
if (messageInfo.providerID && messageInfo.modelID) {
const matchingAgent = agents.find((agent) => agent.model?.providerID === messageInfo.providerID && agent.model?.modelID === messageInfo.modelID);
if (matchingAgent) {
return matchingAgent.name;
}
}
const { currentAgentContext } = get();
const contextAgent = currentAgentContext.get(sessionId);
if (contextAgent && agents.find((a) => a.name === contextAgent)) {
return contextAgent;
}
if (messageIndex > 0 && messageInfo.providerID && messageInfo.modelID) {
const sessionMessages = messages.get(sessionId) || [];
const assistantMessages = sessionMessages.filter((m) => m.info.role === "assistant").sort((a, b) => a.info.time.created - b.info.time.created);
for (let i = messageIndex - 1; i >= 0; i--) {
const prevMessage = assistantMessages[i];
const prevInfo = prevMessage.info as any;
if (prevInfo.providerID === messageInfo.providerID && prevInfo.modelID === messageInfo.modelID) {
if (prevInfo.mode && typeof prevInfo.mode === "string") {
const prevModeAgent = agents.find((a) => a.name === prevInfo.mode);
if (prevModeAgent) {
return prevInfo.mode;
}
}
const prevMatchingAgent = agents.find((agent) => agent.model?.providerID === prevInfo.providerID && agent.model?.modelID === prevInfo.modelID);
if (prevMatchingAgent) {
return prevMatchingAgent.name;
}
}
}
}
if (messageInfo.providerID && messageInfo.modelID) {
const buildAgent = agents.find((a) => a.name === "build");
if (buildAgent) {
return "build";
}
}
return null;
};
const sessionMessages = messages.get(sessionId) || [];
const allMessages = sessionMessages.filter((m: any) => m.info.role === "assistant" || m.info.role === "user").sort((a: any, b: any) => a.info.time.created - b.info.time.created);
const assistantMessages = sessionMessages.filter((m: any) => m.info.role === "assistant").sort((a: any, b: any) => a.info.time.created - b.info.time.created);
for (let messageIndex = 0; messageIndex < allMessages.length; messageIndex++) {
const message = allMessages[messageIndex];
const { info } = message;
const infoAny = info as any;
if (infoAny.providerID && infoAny.modelID) {
const agentName = extractAgentFromMessage(infoAny, assistantMessages.indexOf(message));
if (agentName && agents.find((a) => a.name === agentName)) {
const choice = {
providerId: infoAny.providerID,
modelId: infoAny.modelID,
timestamp: info.time.created,
};
const existing = agentLastChoices.get(agentName);
if (!existing || choice.timestamp > existing.timestamp) {
agentLastChoices.set(agentName, choice);
}
}
}
}
for (const [agentName, choice] of agentLastChoices) {
saveAgentModelForSession(sessionId, agentName, choice.providerId, choice.modelId);
}
return agentLastChoices;
},
getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => {
if (!sessionId) return null;
const limitsUsage = calculateContextUsage(0, contextLimit, outputLimit);
const safeContext = limitsUsage.contextLimit;
const normalizedOutput = limitsUsage.normalizedOutput ?? 0;
const thresholdLimit = limitsUsage.thresholdLimit;
if (safeContext === 0 || thresholdLimit === 0) {
return null;
}
const sessionMessages = messages.get(sessionId) || [];
const assistantMessages = sessionMessages.filter(m => m.info.role === 'assistant');
if (assistantMessages.length === 0) return null;
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1];
const lastMessageId = lastAssistantMessage.info.id;
const scheduleUsageUpdate = (usage: ContextUsage) => {
const runUpdate = () => {
set((state) => {
const existing = state.sessionContextUsage.get(sessionId) as ContextUsage | undefined;
if (
existing &&
existing.totalTokens === usage.totalTokens &&
existing.percentage === usage.percentage &&
existing.contextLimit === usage.contextLimit &&
(existing.outputLimit ?? 0) === (usage.outputLimit ?? 0) &&
(existing.normalizedOutput ?? 0) === (usage.normalizedOutput ?? 0) &&
existing.thresholdLimit === usage.thresholdLimit &&
existing.lastMessageId === usage.lastMessageId
) {
return state;
}
const newContextUsage = new Map(state.sessionContextUsage);
newContextUsage.set(sessionId, usage);
return { sessionContextUsage: newContextUsage };
});
};
if (typeof queueMicrotask === 'function') {
queueMicrotask(runUpdate);
} else if (typeof window !== 'undefined') {
window.setTimeout(runUpdate, 0);
} else {
setTimeout(runUpdate, 0);
}
};
const cachedUsage = get().sessionContextUsage.get(sessionId) as ContextUsage | undefined;
if (cachedUsage && cachedUsage.lastMessageId === lastMessageId) {
const cachedOutput = cachedUsage.normalizedOutput ?? cachedUsage.outputLimit ?? 0;
const limitsChanged =
cachedUsage.contextLimit !== safeContext ||
cachedOutput !== normalizedOutput ||
cachedUsage.thresholdLimit !== thresholdLimit;
if (limitsChanged && cachedUsage.totalTokens > 0) {
const newPercentage = (cachedUsage.totalTokens / thresholdLimit) * 100;
const recalculated: ContextUsage = {
totalTokens: cachedUsage.totalTokens,
percentage: Math.min(newPercentage, 100),
contextLimit: safeContext,
outputLimit: limitsUsage.outputLimit,
normalizedOutput,
thresholdLimit,
lastMessageId,
};
scheduleUsageUpdate(recalculated);
return recalculated;
}
if (!limitsChanged && cachedUsage.totalTokens > 0) {
return cachedUsage;
}
}
const totalTokens = extractTokensFromMessage(lastAssistantMessage);
if (totalTokens === 0) {
return cachedUsage || null;
}
const usage = calculateContextUsage(totalTokens, contextLimit, outputLimit);
const result: ContextUsage = {
totalTokens,
percentage: usage.percentage,
contextLimit: usage.contextLimit,
outputLimit: usage.outputLimit,
normalizedOutput: usage.normalizedOutput,
thresholdLimit: usage.thresholdLimit,
lastMessageId,
};
scheduleUsageUpdate(result);
return result;
},
updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => {
const sessionMessages = messages.get(sessionId) || [];
const assistantMessages = sessionMessages.filter(m => m.info.role === 'assistant');
if (assistantMessages.length === 0) return;
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1];
const totalTokens = extractTokensFromMessage(lastAssistantMessage);
if (totalTokens === 0) return;
const usage = calculateContextUsage(totalTokens, contextLimit, outputLimit);
set((state) => {
const newContextUsage = new Map(state.sessionContextUsage);
newContextUsage.set(sessionId, {
totalTokens,
percentage: usage.percentage,
contextLimit: usage.contextLimit,
outputLimit: usage.outputLimit,
normalizedOutput: usage.normalizedOutput,
thresholdLimit: usage.thresholdLimit,
lastMessageId: lastAssistantMessage.info.id,
});
return { sessionContextUsage: newContextUsage };
});
},
initializeSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => {
const state = get();
const existingUsage = state.sessionContextUsage.get(sessionId);
if (!existingUsage || existingUsage.totalTokens === 0) {
get().updateSessionContextUsage(sessionId, contextLimit, outputLimit, messages);
}
},
pollForTokenUpdates: (sessionId: string, messageId: string, messages: Map<string, { info: any; parts: any[] }[]>, maxAttempts: number = 10) => {
let attempts = 0;
const poll = () => {
attempts++;
const sessionMessages = messages.get(sessionId) || [];
const message = sessionMessages.find(m => m.info.id === messageId);
if (message && message.info.role === 'assistant') {
const totalTokens = extractTokensFromMessage(message);
if (totalTokens > 0) {
get().updateSessionContextUsage(sessionId, 0, 0, messages);
return;
}
}
if (attempts < maxAttempts) {
setTimeout(poll, 1000);
}
};
setTimeout(poll, 2000);
},
getCurrentAgent: (sessionId: string) => {
const { currentAgentContext } = get();
return currentAgentContext.get(sessionId);
},
getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode: EditPermissionMode = getAgentDefaultEditPermission(agentName)) => {
if (!sessionId || !agentName) {
return defaultMode;
}
const sessionMap = get().sessionAgentEditModes.get(sessionId);
const override = sessionMap?.get(agentName);
return override ?? defaultMode;
},
setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode: EditPermissionMode = getAgentDefaultEditPermission(agentName)) => {
if (!sessionId || !agentName) {
return;
}
const normalizedDefault: EditPermissionMode = defaultMode ?? 'ask';
if (normalizedDefault === 'deny' || mode === 'deny') {
return;
}
if (!EDIT_PERMISSION_SEQUENCE.includes(mode)) {
return;
}
set((state) => {
const nextMap = new Map(state.sessionAgentEditModes);
const agentMap = new Map(nextMap.get(sessionId) ?? new Map());
if (mode === normalizedDefault) {
agentMap.delete(agentName);
if (agentMap.size === 0) {
nextMap.delete(sessionId);
} else {
nextMap.set(sessionId, agentMap);
}
} else {
agentMap.set(agentName, mode);
nextMap.set(sessionId, agentMap);
}
return { sessionAgentEditModes: nextMap };
});
},
toggleSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode: EditPermissionMode = getAgentDefaultEditPermission(agentName)) => {
if (!sessionId || !agentName) {
return;
}
const normalizedDefault: EditPermissionMode = defaultMode ?? 'ask';
if (normalizedDefault === 'deny') {
return;
}
const currentMode = get().getSessionAgentEditMode(sessionId, agentName, normalizedDefault);
const currentIndex = EDIT_PERMISSION_SEQUENCE.indexOf(currentMode);
const fallbackIndex = EDIT_PERMISSION_SEQUENCE.indexOf(normalizedDefault);
const baseIndex = currentIndex >= 0 ? currentIndex : (fallbackIndex >= 0 ? fallbackIndex : 0);
const nextIndex = (baseIndex + 1) % EDIT_PERMISSION_SEQUENCE.length;
const nextMode = EDIT_PERMISSION_SEQUENCE[nextIndex];
get().setSessionAgentEditMode(sessionId, agentName, nextMode, normalizedDefault);
},
}),
{
name: "context-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
sessionModelSelections: Array.from(state.sessionModelSelections.entries()),
sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()),
sessionAgentModelSelections: Array.from(state.sessionAgentModelSelections.entries()).map(([sessionId, agentMap]) => [sessionId, Array.from(agentMap.entries())]),
currentAgentContext: Array.from(state.currentAgentContext.entries()),
sessionContextUsage: Array.from(state.sessionContextUsage.entries()),
sessionAgentEditModes: Array.from(state.sessionAgentEditModes.entries()).map(([sessionId, agentMap]) => [sessionId, Array.from(agentMap.entries())]),
}),
merge: (persistedState: any, currentState) => {
const agentModelSelections = new Map();
if (persistedState?.sessionAgentModelSelections) {
persistedState.sessionAgentModelSelections.forEach(([sessionId, agentArray]: [string, any[]]) => {
agentModelSelections.set(sessionId, new Map(agentArray));
});
}
const agentEditModes = new Map();
if (persistedState?.sessionAgentEditModes) {
persistedState.sessionAgentEditModes.forEach(([sessionId, agentArray]: [string, any[]]) => {
agentEditModes.set(sessionId, new Map(agentArray));
});
}
return {
...currentState,
...(persistedState as object),
sessionModelSelections: new Map(persistedState?.sessionModelSelections || []),
sessionAgentSelections: new Map(persistedState?.sessionAgentSelections || []),
sessionAgentModelSelections: agentModelSelections,
currentAgentContext: new Map(persistedState?.currentAgentContext || []),
sessionContextUsage: new Map(persistedState?.sessionContextUsage || []),
sessionAgentEditModes: agentEditModes,
hasHydrated: true,
};
},
}
),
{
name: "context-store",
}
)
);
+235
View File
@@ -0,0 +1,235 @@
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import type { AttachedFile } from "./types/sessionTypes";
import { getSafeStorage } from "./utils/safeStorage";
interface FileState {
attachedFiles: AttachedFile[];
}
interface FileActions {
addAttachedFile: (file: File) => Promise<void>;
addServerFile: (path: string, name: string, content?: string) => Promise<void>;
removeAttachedFile: (id: string) => void;
clearAttachedFiles: () => void;
}
type FileStore = FileState & FileActions;
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const guessMimeType = (file: File): string => {
if (file.type && file.type.trim().length > 0) {
return file.type;
}
const name = (file.name || "").toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() || "" : "";
const noExtNames = new Set([
"license",
"readme",
"changelog",
"notice",
"authors",
"copying",
]);
if (noExtNames.has(name)) return "text/plain";
switch (ext) {
case "md":
case "markdown":
return "text/markdown";
case "txt":
return "text/plain";
case "json":
return "application/json";
case "yaml":
case "yml":
return "application/x-yaml";
case "ts":
case "tsx":
case "js":
case "jsx":
case "mjs":
case "cjs":
case "py":
case "rb":
case "sh":
case "bash":
case "zsh":
return "text/plain";
default:
return "application/octet-stream";
}
};
export const useFileStore = create<FileStore>()(
devtools(
persist(
(set, get) => ({
attachedFiles: [],
addAttachedFile: async (file: File) => {
const { attachedFiles } = get();
const isDuplicate = attachedFiles.some((f) => f.filename === file.name && f.size === file.size);
if (isDuplicate) {
console.log(`File "${file.name}" is already attached`);
return;
}
const maxSize = MAX_ATTACHMENT_SIZE;
if (file.size > maxSize) {
throw new Error(`File "${file.name}" is too large. Maximum size is 10MB.`);
}
const allowedTypes = [
"text/",
"application/json",
"application/xml",
"application/pdf",
"image/",
"video/",
"audio/",
"application/javascript",
"application/typescript",
"application/x-python",
"application/x-ruby",
"application/x-sh",
"application/yaml",
"application/octet-stream",
];
const mimeType = guessMimeType(file);
const isAllowed = allowedTypes.some((type) => mimeType.startsWith(type) || mimeType === type || mimeType === "");
if (!isAllowed && mimeType !== "") {
console.warn(`File type "${mimeType}" might not be supported`);
}
const reader = new FileReader();
const rawDataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const dataUrl = rawDataUrl.startsWith("data:")
? rawDataUrl.replace(/^data:[^;]*/, `data:${mimeType}`)
: rawDataUrl;
const extractFilename = (fullPath: string) => {
const parts = fullPath.replace(/\\/g, "/").split("/");
return parts[parts.length - 1] || fullPath;
};
const attachedFile: AttachedFile = {
id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
file,
dataUrl,
mimeType,
filename: extractFilename(file.name),
size: file.size,
source: "local",
};
set((state) => ({
attachedFiles: [...state.attachedFiles, attachedFile],
}));
},
addServerFile: async (path: string, name: string, content?: string) => {
const { attachedFiles } = get();
const isDuplicate = attachedFiles.some((f) => f.serverPath === path && f.source === "server");
if (isDuplicate) {
console.log(`Server file "${name}" is already attached`);
return;
}
let fileContent = content;
if (!fileContent) {
try {
const tempClient = opencodeClient.getApiClient();
const lastSlashIndex = path.lastIndexOf("/");
const directory = lastSlashIndex > 0 ? path.substring(0, lastSlashIndex) : "/";
const filename = lastSlashIndex > 0 ? path.substring(lastSlashIndex + 1) : path;
const response = await tempClient.file.read({
query: {
path: filename,
directory: directory,
},
});
if (response.data && "content" in response.data) {
fileContent = response.data.content;
} else {
fileContent = "";
}
} catch (error) {
console.error("Failed to read server file:", error);
fileContent = `[File: ${name}]`;
}
}
const blob = new Blob([fileContent || ""], { type: "text/plain" });
if (blob.size > MAX_ATTACHMENT_SIZE) {
throw new Error(`File "${name}" is too large. Maximum size is 10MB.`);
}
const file = new File([blob], name, { type: "text/plain" });
const encoder = new TextEncoder();
const data = encoder.encode(fileContent || "");
const base64 = btoa(String.fromCharCode(...data));
const dataUrl = `data:text/plain;base64,${base64}`;
const attachedFile: AttachedFile = {
id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
file,
dataUrl,
mimeType: "text/plain",
filename: name,
size: blob.size,
source: "server",
serverPath: path,
};
set((state) => ({
attachedFiles: [...state.attachedFiles, attachedFile],
}));
},
removeAttachedFile: (id: string) => {
set((state) => ({
attachedFiles: state.attachedFiles.filter((f) => f.id !== id),
}));
},
clearAttachedFiles: () => {
set({ attachedFiles: [] });
},
}),
{
name: "file-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
attachedFiles: state.attachedFiles,
}),
}
),
{
name: "file-store",
}
)
);
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import type { Permission, PermissionResponse } from "@/types/permission";
import { isEditPermissionType, getAgentDefaultEditPermission } from "./utils/permissionUtils";
import { getSafeStorage } from "./utils/safeStorage";
import { useMessageStore } from "./messageStore";
import { useSessionStore } from "./sessionStore";
interface PermissionState {
permissions: Map<string, Permission[]>;
}
interface PermissionActions {
addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void;
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise<void>;
}
type PermissionStore = PermissionState & PermissionActions;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const sanitizePermissionEntries = (value: unknown): Array<[string, Permission[]]> => {
if (!Array.isArray(value)) {
return [];
}
const entries: Array<[string, Permission[]]> = [];
value.forEach((entry) => {
if (!Array.isArray(entry) || entry.length !== 2) {
return;
}
const [sessionId, permissions] = entry;
if (typeof sessionId !== "string" || !Array.isArray(permissions)) {
return;
}
entries.push([sessionId, permissions as Permission[]]);
});
return entries;
};
const executeWithPermissionDirectory = async <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => {
try {
const sessionStore = useSessionStore.getState();
const metadata = sessionStore.getWorktreeMetadata(sessionId);
if (metadata?.path) {
return opencodeClient.withDirectory(metadata.path, operation);
}
const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { directory?: string } | undefined;
const directory =
typeof session?.directory === 'string' && session.directory.length > 0 ? session.directory : undefined;
if (directory) {
return opencodeClient.withDirectory(directory, operation);
}
} catch (error) {
console.warn('Failed to resolve session directory for permission handling:', error);
}
return operation();
};
export const usePermissionStore = create<PermissionStore>()(
devtools(
persist(
(set, get) => ({
permissions: new Map(),
addPermission: (permission: Permission, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => {
const sessionId = permission.sessionID;
if (!sessionId) {
return;
}
const permissionType = permission.type?.toLowerCase?.() ?? null;
let agentName = contextData?.currentAgentContext?.get(sessionId);
if (!agentName) {
agentName = contextData?.sessionAgentSelections?.get(sessionId) ?? undefined;
}
if (!agentName) {
if (typeof window !== "undefined") {
const configStore = window.__zustand_config_store__;
if (configStore?.getState) {
agentName = configStore.getState().currentAgentName ?? undefined;
}
}
}
const defaultMode = getAgentDefaultEditPermission(agentName);
const effectiveMode = contextData?.getSessionAgentEditMode?.(sessionId, agentName) ?? defaultMode;
const shouldAutoApprove = effectiveMode === 'full'
|| (effectiveMode === 'allow' && isEditPermissionType(permissionType));
if (shouldAutoApprove) {
get().respondToPermission(sessionId, permission.id, 'once').catch(() => {
});
return;
}
set((state) => {
const sessionPermissions = state.permissions.get(sessionId) || [];
const newPermissions = new Map(state.permissions);
newPermissions.set(sessionId, [...sessionPermissions, permission]);
return { permissions: newPermissions };
});
},
respondToPermission: async (sessionId: string, permissionId: string, response: PermissionResponse) => {
await executeWithPermissionDirectory(sessionId, () => opencodeClient.respondToPermission(sessionId, permissionId, response));
if (response === 'reject') {
const messageStore = useMessageStore.getState();
await messageStore.abortCurrentOperation(sessionId);
}
set((state) => {
const sessionPermissions = state.permissions.get(sessionId) || [];
const updatedPermissions = sessionPermissions.filter((p) => p.id !== permissionId);
const newPermissions = new Map(state.permissions);
newPermissions.set(sessionId, updatedPermissions);
return { permissions: newPermissions };
});
},
}),
{
name: "permission-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
permissions: Array.from(state.permissions.entries()),
}),
merge: (persistedState, currentState) => {
if (!isRecord(persistedState)) {
return currentState;
}
const entries = sanitizePermissionEntries(persistedState.permissions);
return {
...currentState,
permissions: new Map(entries),
};
},
}
),
{
name: "permission-store",
}
)
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
import type { Session, Message, Part } from "@opencode-ai/sdk";
import type { Permission, PermissionResponse } from "@/types/permission";
export interface AttachedFile {
id: string;
file: File;
dataUrl: string;
mimeType: string;
filename: string;
size: number;
source: "local" | "server";
serverPath?: string;
}
export type EditPermissionMode = 'allow' | 'ask' | 'deny' | 'full';
export type MessageStreamPhase = 'streaming' | 'cooldown' | 'completed';
export interface MessageStreamLifecycle {
phase: MessageStreamPhase;
startedAt: number;
lastUpdateAt: number;
completedAt?: number;
}
export interface SessionMemoryState {
viewportAnchor: number;
isStreaming: boolean;
streamStartTime?: number;
lastAccessedAt: number;
backgroundMessageCount: number;
isZombie?: boolean;
totalAvailableMessages?: number;
hasMoreAbove?: boolean;
trimmedHeadMaxId?: string;
streamingCooldownUntil?: number;
}
export interface SessionContextUsage {
totalTokens: number;
percentage: number;
contextLimit: number;
outputLimit?: number;
normalizedOutput?: number;
thresholdLimit: number;
lastMessageId?: string;
}
export const MEMORY_LIMITS = {
MAX_SESSIONS: 2,
VIEWPORT_MESSAGES: 60,
STREAMING_BUFFER: Infinity,
BACKGROUND_STREAMING_BUFFER: 100,
ZOMBIE_TIMEOUT: 10 * 60 * 1000,
} as const;
export const ACTIVE_SESSION_WINDOW = 120;
export interface SessionStore {
sessions: Session[];
currentSessionId: string | null;
lastLoadedDirectory: string | null;
messages: Map<string, { info: Message; parts: Part[] }[]>;
sessionMemoryState: Map<string, SessionMemoryState>;
messageStreamStates: Map<string, MessageStreamLifecycle>;
sessionCompactionUntil: Map<string, number>;
permissions: Map<string, Permission[]>;
sessionAbortFlags: Map<string, { timestamp: number; acknowledged: boolean }>;
attachedFiles: AttachedFile[];
abortPromptSessionId: string | null;
abortPromptExpiresAt: number | null;
isLoading: boolean;
error: string | null;
streamingMessageIds: Map<string, string | null>;
abortControllers: Map<string, AbortController>;
lastUsedProvider: { providerID: string; modelID: string } | null;
isSyncing: boolean;
sessionModelSelections: Map<string, { providerId: string; modelId: string }>;
sessionAgentSelections: Map<string, string>;
sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>;
webUICreatedSessions: Set<string>;
worktreeMetadata: Map<string, import('@/types/worktree').WorktreeMetadata>;
availableWorktrees: import('@/types/worktree').WorktreeMetadata[];
currentAgentContext: Map<string, string>;
sessionContextUsage: Map<string, SessionContextUsage>;
sessionAgentEditModes: Map<string, Map<string, EditPermissionMode>>;
sessionActivityPhase?: Map<string, 'idle' | 'busy' | 'cooldown'>;
userSummaryTitles: Map<string, { title: string; createdAt: number | null }>;
getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => EditPermissionMode;
toggleSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => void;
setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void;
loadSessions: () => Promise<void>;
createSession: (title?: string, directoryOverride?: string | null) => Promise<Session | null>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>;
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
updateSessionTitle: (id: string, title: string) => Promise<void>;
shareSession: (id: string) => Promise<Session | null>;
unshareSession: (id: string) => Promise<Session | null>;
setCurrentSession: (id: string | null) => void;
loadMessages: (sessionId: string) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string) => Promise<void>;
abortCurrentOperation: () => Promise<void>;
acknowledgeSessionAbort: (sessionId: string) => void;
armAbortPrompt: (durationMs?: number) => number | null;
clearAbortPrompt: () => void;
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string) => void;
completeStreamingMessage: (sessionId: string, messageId: string) => void;
markMessageStreamSettled: (messageId: string) => void;
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Message) => void;
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void;
addPermission: (permission: Permission) => void;
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => Promise<void>;
clearError: () => void;
getSessionsByDirectory: (directory: string) => Session[];
getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null;
getCurrentAgent: (sessionId: string) => string | undefined;
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void;
applySessionMetadata: (sessionId: string, metadata: Partial<Session>) => void;
setSessionDirectory: (sessionId: string, directory: string | null) => void;
addAttachedFile: (file: File) => Promise<void>;
addServerFile: (path: string, name: string, content?: string) => Promise<void>;
removeAttachedFile: (id: string) => void;
clearAttachedFiles: () => void;
updateViewportAnchor: (sessionId: string, anchor: number) => void;
trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
evictLeastRecentlyUsed: () => void;
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void;
getSessionModelSelection: (sessionId: string) => { providerId: string; modelId: string } | null;
saveSessionAgentSelection: (sessionId: string, agentName: string) => void;
getSessionAgentSelection: (sessionId: string) => string | null;
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null;
analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Array<{ name: string; [key: string]: unknown }>) => Promise<Map<string, { providerId: string; modelId: string; timestamp: number }>>;
isOpenChamberCreatedSession: (sessionId: string) => boolean;
markSessionAsOpenChamberCreated: (sessionId: string) => void;
initializeNewOpenChamberSession: (sessionId: string, agents: Array<{ name: string; [key: string]: unknown }>) => void;
setWorktreeMetadata: (sessionId: string, metadata: import('@/types/worktree').WorktreeMetadata | null) => void;
getWorktreeMetadata: (sessionId: string) => import('@/types/worktree').WorktreeMetadata | undefined;
getContextUsage: (contextLimit: number, outputLimit: number) => SessionContextUsage | null;
updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => void;
initializeSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => void;
debugSessionMessages: (sessionId: string) => Promise<void>;
pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => void;
updateSession: (session: Session) => void;
}
+417
View File
@@ -0,0 +1,417 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import type { Agent } from "@opencode-ai/sdk";
import { opencodeClient } from "@/lib/opencode/client";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import {
startConfigUpdate,
finishConfigUpdate,
updateConfigUpdateMessage,
} from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore";
export interface AgentConfig {
name: string;
description?: string;
model?: string | null;
temperature?: number;
top_p?: number;
prompt?: string;
mode?: "primary" | "subagent" | "all";
tools?: Record<string, boolean>;
permission?: {
edit?: "allow" | "ask" | "deny" | "full";
bash?: "allow" | "ask" | "deny" | Record<string, "allow" | "ask" | "deny">;
webfetch?: "allow" | "ask" | "deny";
};
disable?: boolean;
}
const CONFIG_EVENT_SOURCE = "useAgentsStore";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const MAX_HEALTH_WAIT_MS = 20000;
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
const FAST_HEALTH_POLL_ATTEMPTS = 4;
const SLOW_HEALTH_POLL_BASE_MS = 800;
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
const SLOW_HEALTH_POLL_MAX_MS = 2000;
interface AgentsStore {
selectedAgentName: string | null;
agents: Agent[];
isLoading: boolean;
setSelectedAgent: (name: string | null) => void;
loadAgents: () => Promise<boolean>;
createAgent: (config: AgentConfig) => Promise<boolean>;
updateAgent: (name: string, config: Partial<AgentConfig>) => Promise<boolean>;
deleteAgent: (name: string) => Promise<boolean>;
getAgentByName: (name: string) => Agent | undefined;
}
declare global {
interface Window {
__zustand_agents_store__?: UseBoundStore<StoreApi<AgentsStore>>;
}
}
export const useAgentsStore = create<AgentsStore>()(
devtools(
persist(
(set, get) => ({
selectedAgentName: null,
agents: [],
isLoading: false,
setSelectedAgent: (name: string | null) => {
set({ selectedAgentName: name });
},
loadAgents: async () => {
set({ isLoading: true });
const previousAgents = get().agents;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const agents = await opencodeClient.listAgents();
set({ agents, isLoading: false });
return true;
} catch (error) {
lastError = error;
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
console.error("Failed to load agents:", lastError);
set({ agents: previousAgents, isLoading: false });
return false;
},
createAgent: async (config: AgentConfig) => {
startConfigUpdate("Creating agent configuration…");
let requiresReload = false;
try {
console.log('[AgentsStore] Creating agent:', config.name);
const agentConfig: Record<string, unknown> = {
mode: config.mode || "subagent",
};
if (config.description) agentConfig.description = config.description;
if (config.model) agentConfig.model = config.model;
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
if (config.prompt) agentConfig.prompt = config.prompt;
if (config.tools && Object.keys(config.tools).length > 0) agentConfig.tools = config.tools;
if (config.permission) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable;
console.log('[AgentsStore] Agent config to save:', agentConfig);
const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(agentConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to create agent';
throw new Error(message);
}
console.log('[AgentsStore] Agent created successfully');
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await performFullConfigRefresh({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadAgents();
if (loaded) {
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch (error) {
console.error("[AgentsStore] Failed to create agent:", error);
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
updateAgent: async (name: string, config: Partial<AgentConfig>) => {
startConfigUpdate("Updating agent configuration…");
let requiresReload = false;
try {
console.log('[AgentsStore] Updating agent:', name);
console.log('[AgentsStore] Config received:', config);
const agentConfig: Record<string, unknown> = {};
if (config.mode !== undefined) agentConfig.mode = config.mode;
if (config.description !== undefined) agentConfig.description = config.description;
if (config.model !== undefined) agentConfig.model = config.model;
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
if (config.prompt !== undefined) agentConfig.prompt = config.prompt;
if (config.tools !== undefined) agentConfig.tools = config.tools;
if (config.permission !== undefined) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable;
console.log('[AgentsStore] Agent config to update:', agentConfig);
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(agentConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to update agent';
throw new Error(message);
}
console.log('[AgentsStore] Agent updated successfully');
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await performFullConfigRefresh({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadAgents();
if (loaded) {
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch (error) {
console.error("[AgentsStore] Failed to update agent:", error);
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
deleteAgent: async (name: string) => {
startConfigUpdate("Deleting agent configuration…");
let requiresReload = false;
try {
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to delete agent';
throw new Error(message);
}
console.log('[AgentsStore] Agent deleted successfully');
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await performFullConfigRefresh({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadAgents();
if (loaded) {
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
if (get().selectedAgentName === name) {
set({ selectedAgentName: null });
}
return loaded;
} catch (error) {
console.error("Failed to delete agent:", error);
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
getAgentByName: (name: string) => {
const { agents } = get();
return agents.find((a) => a.name === name);
},
}),
{
name: "agents-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
selectedAgentName: state.selectedAgentName,
}),
},
),
{
name: "agents-store",
},
),
);
if (typeof window !== "undefined") {
window.__zustand_agents_store__ = useAgentsStore;
}
async function waitForOpenCodeConnection(delayMs?: number) {
const initialPause = typeof delayMs === "number" && delayMs > 0
? Math.min(delayMs, FAST_HEALTH_POLL_INTERVAL_MS)
: 0;
if (initialPause > 0) {
await sleep(initialPause);
}
const start = Date.now();
let attempt = 0;
let lastError: unknown = null;
while (Date.now() - start < MAX_HEALTH_WAIT_MS) {
attempt += 1;
updateConfigUpdateMessage(`Waiting for OpenCode… (attempt ${attempt})`);
try {
const isHealthy = await opencodeClient.checkHealth();
if (isHealthy) {
return;
}
lastError = new Error("OpenCode health check reported not ready");
} catch (error) {
lastError = error;
}
const elapsed = Date.now() - start;
const waitMs =
attempt <= FAST_HEALTH_POLL_ATTEMPTS && elapsed < 1200
? FAST_HEALTH_POLL_INTERVAL_MS
: Math.min(
SLOW_HEALTH_POLL_BASE_MS +
Math.max(0, attempt - FAST_HEALTH_POLL_ATTEMPTS) * SLOW_HEALTH_POLL_INCREMENT_MS,
SLOW_HEALTH_POLL_MAX_MS,
);
await sleep(waitMs);
}
throw lastError || new Error("OpenCode did not become ready in time");
}
async function performFullConfigRefresh(options: { message?: string; delayMs?: number } = {}) {
const { message, delayMs } = options;
try {
updateConfigUpdateMessage(message || "Reloading OpenCode configuration…");
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.removeItem("agents-store");
window.localStorage.removeItem("config-store");
}
} catch (error) {
console.warn("[AgentsStore] Failed to prepare config refresh:", error);
}
try {
await waitForOpenCodeConnection(delayMs);
updateConfigUpdateMessage("Refreshing providers and agents…");
const configStore = useConfigStore.getState();
const agentsStore = useAgentsStore.getState();
await Promise.all([
configStore.loadProviders().then(() => undefined),
agentsStore.loadAgents().then(() => undefined),
]);
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
} catch (error) {
console.error("[AgentsStore] Failed to refresh configuration after OpenCode restart:", error);
updateConfigUpdateMessage("OpenCode reload failed. Please retry refreshing configuration manually.");
await sleep(1500);
} finally {
finishConfigUpdate();
}
}
export async function refreshAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) {
await performFullConfigRefresh(options);
}
export async function reloadOpenCodeConfiguration(options?: { message?: string; delayMs?: number }) {
startConfigUpdate(options?.message || "Reloading OpenCode configuration…");
try {
const response = await fetch('/api/config/reload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to reload configuration';
throw new Error(message);
}
if (payload?.requiresReload) {
await performFullConfigRefresh({
message: payload.message,
delayMs: payload.reloadDelayMs,
});
} else {
await performFullConfigRefresh(options);
}
} catch (error) {
console.error('[reloadOpenCodeConfiguration] Failed:', error);
updateConfigUpdateMessage('Failed to reload configuration. Please try again.');
await sleep(2000);
finishConfigUpdate();
throw error;
}
}
let unsubscribeAgentsConfigChanges: (() => void) | null = null;
if (!unsubscribeAgentsConfigChanges) {
unsubscribeAgentsConfigChanges = subscribeToConfigChanges((event) => {
if (event.source === CONFIG_EVENT_SOURCE) {
return;
}
if (scopeMatches(event, "agents")) {
const { loadAgents } = useAgentsStore.getState();
void loadAgents();
}
});
}
+397
View File
@@ -0,0 +1,397 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import {
startConfigUpdate,
finishConfigUpdate,
updateConfigUpdateMessage,
} from "@/lib/configUpdate";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import { getSafeStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore";
export interface CommandConfig {
name: string;
description?: string;
agent?: string | null;
model?: string | null;
template?: string;
subtask?: boolean;
}
export interface Command extends CommandConfig {
isBuiltIn?: boolean;
}
const CONFIG_EVENT_SOURCE = "useCommandsStore";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const MAX_HEALTH_WAIT_MS = 20000;
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
const FAST_HEALTH_POLL_ATTEMPTS = 4;
const SLOW_HEALTH_POLL_BASE_MS = 800;
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
const SLOW_HEALTH_POLL_MAX_MS = 2000;
interface CommandsStore {
selectedCommandName: string | null;
commands: Command[];
isLoading: boolean;
setSelectedCommand: (name: string | null) => void;
loadCommands: () => Promise<boolean>;
createCommand: (config: CommandConfig) => Promise<boolean>;
updateCommand: (name: string, config: Partial<CommandConfig>) => Promise<boolean>;
deleteCommand: (name: string) => Promise<boolean>;
getCommandByName: (name: string) => Command | undefined;
}
declare global {
interface Window {
__zustand_commands_store__?: UseBoundStore<StoreApi<CommandsStore>>;
}
}
export const useCommandsStore = create<CommandsStore>()(
devtools(
persist(
(set, get) => ({
selectedCommandName: null,
commands: [],
isLoading: false,
setSelectedCommand: (name: string | null) => {
set({ selectedCommandName: name });
},
loadCommands: async () => {
set({ isLoading: true });
const previousCommands = get().commands;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const commands = await opencodeClient.listCommandsWithDetails();
set({ commands, isLoading: false });
return true;
} catch (error) {
lastError = error;
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
console.error("Failed to load commands:", lastError);
set({ commands: previousCommands, isLoading: false });
return false;
},
createCommand: async (config: CommandConfig) => {
startConfigUpdate("Creating command configuration…");
let requiresReload = false;
try {
console.log('[CommandsStore] Creating command:', config.name);
const commandConfig: Record<string, unknown> = {
template: config.template || '',
};
if (config.description) commandConfig.description = config.description;
if (config.agent) commandConfig.agent = config.agent;
if (config.model) commandConfig.model = config.model;
if (config.subtask !== undefined) commandConfig.subtask = config.subtask;
console.log('[CommandsStore] Command config to save:', commandConfig);
const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(commandConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to create command';
throw new Error(message);
}
console.log('[CommandsStore] Command created successfully');
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await performFullConfigRefresh({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadCommands();
if (loaded) {
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch (error) {
console.error("[CommandsStore] Failed to create command:", error);
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
updateCommand: async (name: string, config: Partial<CommandConfig>) => {
startConfigUpdate("Updating command configuration…");
let requiresReload = false;
try {
console.log('[CommandsStore] Updating command:', name);
console.log('[CommandsStore] Config received:', config);
const commandConfig: Record<string, unknown> = {};
if (config.description !== undefined) commandConfig.description = config.description;
if (config.agent !== undefined) commandConfig.agent = config.agent;
if (config.model !== undefined) commandConfig.model = config.model;
if (config.template !== undefined) commandConfig.template = config.template;
if (config.subtask !== undefined) commandConfig.subtask = config.subtask;
console.log('[CommandsStore] Command config to update:', commandConfig);
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(commandConfig)
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to update command';
throw new Error(message);
}
console.log('[CommandsStore] Command updated successfully');
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await performFullConfigRefresh({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadCommands();
if (loaded) {
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch (error) {
console.error("[CommandsStore] Failed to update command:", error);
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
deleteCommand: async (name: string) => {
startConfigUpdate("Deleting command configuration…");
let requiresReload = false;
try {
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to delete command';
throw new Error(message);
}
console.log('[CommandsStore] Command deleted successfully');
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await performFullConfigRefresh({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadCommands();
if (loaded) {
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
}
if (get().selectedCommandName === name) {
set({ selectedCommandName: null });
}
return loaded;
} catch (error) {
console.error("Failed to delete command:", error);
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
getCommandByName: (name: string) => {
const { commands } = get();
return commands.find((c) => c.name === name);
},
}),
{
name: "commands-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
selectedCommandName: state.selectedCommandName,
}),
},
),
{
name: "commands-store",
},
),
);
if (typeof window !== "undefined") {
window.__zustand_commands_store__ = useCommandsStore;
}
async function waitForOpenCodeConnection(delayMs?: number) {
const initialPause = typeof delayMs === "number" && delayMs > 0
? Math.min(delayMs, FAST_HEALTH_POLL_INTERVAL_MS)
: 0;
if (initialPause > 0) {
await sleep(initialPause);
}
const start = Date.now();
let attempt = 0;
let lastError: unknown = null;
while (Date.now() - start < MAX_HEALTH_WAIT_MS) {
attempt += 1;
updateConfigUpdateMessage(`Waiting for OpenCode… (attempt ${attempt})`);
try {
const isHealthy = await opencodeClient.checkHealth();
if (isHealthy) {
return;
}
lastError = new Error("OpenCode health check reported not ready");
} catch (error) {
lastError = error;
}
const elapsed = Date.now() - start;
const waitMs =
attempt <= FAST_HEALTH_POLL_ATTEMPTS && elapsed < 1200
? FAST_HEALTH_POLL_INTERVAL_MS
: Math.min(
SLOW_HEALTH_POLL_BASE_MS +
Math.max(0, attempt - FAST_HEALTH_POLL_ATTEMPTS) * SLOW_HEALTH_POLL_INCREMENT_MS,
SLOW_HEALTH_POLL_MAX_MS,
);
await sleep(waitMs);
}
throw lastError || new Error("OpenCode did not become ready in time");
}
async function performFullConfigRefresh(options: { message?: string; delayMs?: number } = {}) {
const { message, delayMs } = options;
try {
updateConfigUpdateMessage(message || "Reloading OpenCode configuration…");
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.removeItem("commands-store");
window.localStorage.removeItem("config-store");
}
} catch (error) {
console.warn("[CommandsStore] Failed to prepare config refresh:", error);
}
try {
await waitForOpenCodeConnection(delayMs);
updateConfigUpdateMessage("Refreshing providers and commands…");
const configStore = useConfigStore.getState();
const commandsStore = useCommandsStore.getState();
await Promise.all([
configStore.loadProviders().then(() => undefined),
commandsStore.loadCommands().then(() => undefined),
]);
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
} catch (error) {
console.error("[CommandsStore] Failed to refresh configuration after OpenCode restart:", error);
updateConfigUpdateMessage("OpenCode reload failed. Please retry refreshing configuration manually.");
await sleep(1500);
} finally {
finishConfigUpdate();
}
}
export async function reloadOpenCodeConfiguration(options?: { message?: string; delayMs?: number }) {
startConfigUpdate(options?.message || "Reloading OpenCode configuration…");
try {
const response = await fetch('/api/config/reload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to reload configuration';
throw new Error(message);
}
if (payload?.requiresReload) {
await performFullConfigRefresh({
message: payload.message,
delayMs: payload.reloadDelayMs,
});
} else {
await performFullConfigRefresh(options);
}
} catch (error) {
console.error('[reloadOpenCodeConfiguration] Failed:', error);
updateConfigUpdateMessage('Failed to reload configuration. Please try again.');
await sleep(2000);
finishConfigUpdate();
throw error;
}
}
let unsubscribeCommandsConfigChanges: (() => void) | null = null;
if (!unsubscribeCommandsConfigChanges) {
unsubscribeCommandsConfigChanges = subscribeToConfigChanges((event) => {
if (event.source === CONFIG_EVENT_SOURCE) {
return;
}
if (scopeMatches(event, "commands")) {
const { loadCommands } = useCommandsStore.getState();
void loadCommands();
}
});
}
+564
View File
@@ -0,0 +1,564 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import type { Provider, Agent } from "@opencode-ai/sdk";
import { opencodeClient } from "@/lib/opencode/client";
import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import type { ModelMetadata } from "@/types";
import { getSafeStorage } from "./utils/safeStorage";
import type { SessionStore } from "./types/sessionTypes";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
const normalizeProviderId = (value: string) => value?.toLowerCase?.() ?? '';
const isPrimaryMode = (mode?: string) => mode === "primary" || mode === "all" || mode === undefined || mode === null;
type ProviderModel = Provider["models"][string];
type ProviderWithModelList = Omit<Provider, "models"> & { models: ProviderModel[] };
interface ModelsDevModelEntry {
id?: string;
name?: string;
tool_call?: boolean;
reasoning?: boolean;
temperature?: boolean;
attachment?: boolean;
modalities?: {
input?: string[];
output?: string[];
};
cost?: {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
};
limit?: {
context?: number;
output?: number;
};
knowledge?: string;
release_date?: string;
last_updated?: string;
}
interface ModelsDevProviderEntry {
id?: string;
models?: Record<string, ModelsDevModelEntry | undefined>;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === "string");
const isModelsDevModelEntry = (value: unknown): value is ModelsDevModelEntry => {
if (!isRecord(value)) {
return false;
}
const candidate = value as ModelsDevModelEntry;
if (candidate.modalities) {
const { input, output } = candidate.modalities;
if (input && !isStringArray(input)) {
return false;
}
if (output && !isStringArray(output)) {
return false;
}
}
return true;
};
const isModelsDevProviderEntry = (value: unknown): value is ModelsDevProviderEntry => {
if (!isRecord(value)) {
return false;
}
const candidate = value as ModelsDevProviderEntry;
return candidate.models === undefined || isRecord(candidate.models);
};
const buildModelMetadataKey = (providerId: string, modelId: string) => {
const normalizedProvider = normalizeProviderId(providerId);
if (!normalizedProvider || !modelId) {
return '';
}
return `${normalizedProvider}/${modelId}`;
};
const transformModelsDevResponse = (payload: unknown): Map<string, ModelMetadata> => {
const metadataMap = new Map<string, ModelMetadata>();
if (!isRecord(payload)) {
return metadataMap;
}
for (const [providerKey, providerValue] of Object.entries(payload)) {
if (!isModelsDevProviderEntry(providerValue)) {
continue;
}
const providerId = typeof providerValue.id === 'string' && providerValue.id.length > 0 ? providerValue.id : providerKey;
const models = providerValue.models;
if (!models || !isRecord(models)) {
continue;
}
for (const [modelKey, modelValue] of Object.entries(models)) {
if (!isModelsDevModelEntry(modelValue)) {
continue;
}
const resolvedModelId =
typeof modelKey === 'string' && modelKey.length > 0
? modelKey
: modelValue.id;
if (!resolvedModelId || typeof resolvedModelId !== 'string' || resolvedModelId.length === 0) {
continue;
}
const metadata: ModelMetadata = {
id: typeof modelValue.id === 'string' && modelValue.id.length > 0 ? modelValue.id : resolvedModelId,
providerId,
name: typeof modelValue.name === 'string' ? modelValue.name : undefined,
tool_call: typeof modelValue.tool_call === 'boolean' ? modelValue.tool_call : undefined,
reasoning: typeof modelValue.reasoning === 'boolean' ? modelValue.reasoning : undefined,
temperature: typeof modelValue.temperature === 'boolean' ? modelValue.temperature : undefined,
attachment: typeof modelValue.attachment === 'boolean' ? modelValue.attachment : undefined,
modalities: modelValue.modalities
? {
input: isStringArray(modelValue.modalities.input) ? modelValue.modalities.input : undefined,
output: isStringArray(modelValue.modalities.output) ? modelValue.modalities.output : undefined,
}
: undefined,
cost: modelValue.cost,
limit: modelValue.limit,
knowledge: typeof modelValue.knowledge === 'string' ? modelValue.knowledge : undefined,
release_date: typeof modelValue.release_date === 'string' ? modelValue.release_date : undefined,
last_updated: typeof modelValue.last_updated === 'string' ? modelValue.last_updated : undefined,
};
const key = buildModelMetadataKey(providerId, resolvedModelId);
if (key) {
metadataMap.set(key, metadata);
}
}
}
return metadataMap;
};
const fetchModelsDevMetadata = async (): Promise<Map<string, ModelMetadata>> => {
if (typeof fetch !== 'function') {
return new Map();
}
const sources = [MODELS_DEV_PROXY_URL, MODELS_DEV_API_URL];
for (const source of sources) {
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined;
try {
const isAbsoluteUrl = /^https?:\/\//i.test(source);
const requestInit: RequestInit = {
signal: controller?.signal,
headers: {
Accept: 'application/json',
},
cache: 'no-store',
};
if (isAbsoluteUrl) {
requestInit.mode = 'cors';
} else {
requestInit.credentials = 'same-origin';
}
const response = await fetch(source, requestInit);
if (!response.ok) {
throw new Error(`Metadata request to ${source} returned status ${response.status}`);
}
const data = await response.json();
return transformModelsDevResponse(data);
} catch (error: unknown) {
if ((error as Error)?.name === 'AbortError') {
console.warn(`Model metadata request aborted (${source})`);
} else {
console.warn(`Failed to fetch model metadata from ${source}:`, error);
}
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
return new Map();
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
interface ConfigStore {
providers: ProviderWithModelList[];
agents: Agent[];
currentProviderId: string;
currentModelId: string;
currentAgentName: string | undefined;
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
defaultProviders: { [key: string]: string };
isConnected: boolean;
isInitialized: boolean;
modelsMetadata: Map<string, ModelMetadata>;
loadProviders: () => Promise<void>;
loadAgents: () => Promise<boolean>;
setProvider: (providerId: string) => void;
setModel: (modelId: string) => void;
setAgent: (agentName: string | undefined) => void;
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
checkConnection: () => Promise<boolean>;
initializeApp: () => Promise<void>;
getCurrentProvider: () => ProviderWithModelList | undefined;
getCurrentModel: () => ProviderModel | undefined;
getCurrentAgent: () => Agent | undefined;
getModelMetadata: (providerId: string, modelId: string) => ModelMetadata | undefined;
}
declare global {
interface Window {
__zustand_config_store__?: UseBoundStore<StoreApi<ConfigStore>>;
__zustand_session_store__?: UseBoundStore<StoreApi<SessionStore>>;
}
}
export const useConfigStore = create<ConfigStore>()(
devtools(
persist(
(set, get) => ({
providers: [],
agents: [],
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
agentModelSelections: {},
defaultProviders: {},
isConnected: false,
isInitialized: false,
modelsMetadata: new Map<string, ModelMetadata>(),
loadProviders: async () => {
try {
const metadataPromise = fetchModelsDevMetadata();
const apiResult = await opencodeClient.getProviders();
const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : [];
const defaults = apiResult?.default || {};
const processedProviders: ProviderWithModelList[] = providers.map((provider) => {
const modelRecord = provider.models ?? {};
const models: ProviderModel[] = Object.keys(modelRecord).map((modelId) => modelRecord[modelId]);
return {
...provider,
models,
};
});
const defaultProviderId = defaults.provider || processedProviders[0]?.id || "";
const provider = processedProviders.find((p) => p.id === defaultProviderId);
const defaultModelId = defaults.model || provider?.models?.[0]?.id || "";
set({
providers: processedProviders,
defaultProviders: defaults,
currentProviderId: defaultProviderId,
currentModelId: defaultModelId,
});
const metadata = await metadataPromise;
if (metadata.size > 0) {
set({ modelsMetadata: metadata });
}
} catch (error) {
console.error("Failed to load providers:", error);
set({ providers: [], defaultProviders: {}, currentProviderId: "", currentModelId: "" });
}
},
setProvider: (providerId: string) => {
const { providers } = get();
const provider = providers.find((p) => p.id === providerId);
if (provider) {
const firstModel = provider.models[0];
const newModelId = firstModel?.id || "";
set({
currentProviderId: providerId,
currentModelId: newModelId,
});
}
},
setModel: (modelId: string) => {
set({ currentModelId: modelId });
},
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => {
set((state) => ({
agentModelSelections: {
...state.agentModelSelections,
[agentName]: { providerId, modelId },
},
}));
},
getAgentModelSelection: (agentName: string) => {
const { agentModelSelections } = get();
return agentModelSelections[agentName] || null;
},
loadAgents: async () => {
const previousAgents = get().agents;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const agents = await opencodeClient.listAgents();
const safeAgents = Array.isArray(agents) ? agents : [];
set({ agents: safeAgents });
const { providers } = get();
if (safeAgents.length === 0) {
set({ currentAgentName: undefined });
return true;
}
const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode));
const buildAgent = primaryAgents.find((agent) => agent.name === "build");
const defaultAgent = buildAgent || primaryAgents[0] || safeAgents[0];
set({ currentAgentName: defaultAgent.name });
if (defaultAgent?.model?.providerID && defaultAgent?.model?.modelID) {
const agentProvider = providers.find((p) => p.id === defaultAgent.model!.providerID);
if (agentProvider) {
const agentModel = agentProvider.models.find((model) => model.id === defaultAgent.model!.modelID);
if (agentModel) {
set({
currentProviderId: defaultAgent.model!.providerID,
currentModelId: defaultAgent.model!.modelID,
});
}
}
}
return true;
} catch (error) {
lastError = error;
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
console.error("Failed to load agents:", lastError);
set({ agents: previousAgents });
return false;
},
setAgent: (agentName: string | undefined) => {
const { agents, providers } = get();
set({ currentAgentName: agentName });
if (agentName && typeof window !== "undefined") {
const sessionStore = window.__zustand_session_store__;
if (sessionStore) {
const sessionState = sessionStore.getState();
const { currentSessionId, isOpenChamberCreatedSession, initializeNewOpenChamberSession, getAgentModelForSession } = sessionState;
if (currentSessionId) {
sessionStore.setState((state) => {
const newAgentContext = new Map(state.currentAgentContext);
newAgentContext.set(currentSessionId, agentName);
return { currentAgentContext: newAgentContext };
});
}
if (currentSessionId && isOpenChamberCreatedSession(currentSessionId)) {
const existingAgentModel = getAgentModelForSession(currentSessionId, agentName);
if (!existingAgentModel) {
initializeNewOpenChamberSession(currentSessionId, agents);
}
}
}
}
if (agentName && typeof window !== "undefined") {
const sessionStore = window.__zustand_session_store__;
if (sessionStore) {
const { currentSessionId, getAgentModelForSession } = sessionStore.getState();
if (currentSessionId) {
const existingAgentModel = getAgentModelForSession(currentSessionId, agentName);
if (existingAgentModel) {
return;
}
}
}
const agent = agents.find((candidate) => candidate.name === agentName);
if (agent?.model?.providerID && agent?.model?.modelID) {
const agentProvider = providers.find((provider) => provider.id === agent.model!.providerID);
if (agentProvider) {
const agentModel = agentProvider.models.find((model) => model.id === agent.model!.modelID);
if (agentModel) {
set({
currentProviderId: agent.model!.providerID,
currentModelId: agent.model!.modelID,
});
}
}
}
}
},
checkConnection: async () => {
const maxAttempts = 5;
let attempt = 0;
let lastError: unknown = null;
while (attempt < maxAttempts) {
try {
const isHealthy = await opencodeClient.checkHealth();
set({ isConnected: isHealthy });
return isHealthy;
} catch (error) {
lastError = error;
attempt += 1;
const delay = 400 * attempt;
await sleep(delay);
}
}
if (lastError) {
console.warn("[ConfigStore] Failed to reach OpenCode after retrying:", lastError);
}
set({ isConnected: false });
return false;
},
initializeApp: async () => {
try {
console.log("Starting app initialization...");
const isConnected = await get().checkConnection();
console.log("Connection check result:", isConnected);
if (!isConnected) {
console.log("Server not connected");
set({ isConnected: false });
return;
}
console.log("Initializing app...");
await opencodeClient.initApp();
console.log("Loading providers...");
await get().loadProviders();
console.log("Loading agents...");
await get().loadAgents();
set({ isInitialized: true, isConnected: true });
console.log("App initialized successfully");
} catch (error) {
console.error("Failed to initialize app:", error);
set({ isInitialized: false, isConnected: false });
}
},
getCurrentProvider: () => {
const { providers, currentProviderId } = get();
return providers.find((p) => p.id === currentProviderId);
},
getCurrentModel: () => {
const provider = get().getCurrentProvider();
const { currentModelId } = get();
if (!provider) {
return undefined;
}
return provider.models.find((model) => model.id === currentModelId);
},
getCurrentAgent: () => {
const { agents, currentAgentName } = get();
if (!currentAgentName) return undefined;
return agents.find((a) => a.name === currentAgentName);
},
getModelMetadata: (providerId: string, modelId: string) => {
const key = buildModelMetadataKey(providerId, modelId);
if (!key) {
return undefined;
}
const { modelsMetadata } = get();
return modelsMetadata.get(key);
},
}),
{
name: "config-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: () => ({
}),
},
),
{
name: "config-store",
},
),
);
if (typeof window !== "undefined") {
window.__zustand_config_store__ = useConfigStore;
}
let unsubscribeConfigStoreChanges: (() => void) | null = null;
if (!unsubscribeConfigStoreChanges) {
unsubscribeConfigStoreChanges = subscribeToConfigChanges(async (event) => {
const tasks: Promise<void>[] = [];
if (scopeMatches(event, "agents")) {
const { loadAgents } = useConfigStore.getState();
tasks.push(loadAgents().then(() => {}));
}
if (scopeMatches(event, "providers")) {
const { loadProviders } = useConfigStore.getState();
tasks.push(loadProviders());
}
if (tasks.length > 0) {
await Promise.all(tasks);
}
});
}
+477
View File
@@ -0,0 +1,477 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import type { DirectorySwitchResult } from '@/lib/opencode/client';
import { getDesktopHomeDirectory } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate';
import { useSessionStore } from '@/stores/useSessionStore';
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { emitConfigChange } from '@/lib/configSync';
import { getSafeStorage } from './utils/safeStorage';
interface DirectoryStore {
currentDirectory: string;
directoryHistory: string[];
historyIndex: number;
homeDirectory: string;
hasPersistedDirectory: boolean;
isHomeReady: boolean;
isSwitchingDirectory: boolean;
setDirectory: (path: string, options?: { showOverlay?: boolean }) => void;
goBack: () => void;
goForward: () => void;
goToParent: () => void;
goHome: () => Promise<void>;
synchronizeHomeDirectory: (path: string) => void;
}
let cachedHomeDirectory: string | null = null;
const safeStorage = getSafeStorage();
const persistedLastDirectory = safeStorage.getItem('lastDirectory');
const initialHasPersistedDirectory =
typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0;
const notifyOpenCodeWorkingDirectory = (path: string, options?: { showOverlay?: boolean }) => {
const showOverlay = options?.showOverlay ?? true;
if (showOverlay) {
startConfigUpdate('Switching project directory…');
}
return opencodeClient.setOpenCodeWorkingDirectory(path).catch((error) => {
console.warn('Failed to synchronize OpenCode working directory:', error);
throw error;
});
};
const scheduleDirectoryFollowUp = (
restartPromise: Promise<DirectorySwitchResult | null>,
options: { showOverlay: boolean },
onComplete?: (result: DirectorySwitchResult | null) => void
) => {
const { showOverlay } = options;
const reloadSessions = () => {
try {
useSessionStore.getState().loadSessions();
} catch (err) {
console.error('Failed to reload sessions after directory change:', err);
}
};
void (async () => {
let result: DirectorySwitchResult | null = null;
try {
result = await restartPromise;
} catch (error) {
console.error('Failed to update OpenCode working directory:', error);
if (showOverlay) {
updateConfigUpdateMessage('Failed to switch directory. Please try again.');
await new Promise((resolve) => setTimeout(resolve, 1500));
finishConfigUpdate();
}
onComplete?.(result);
reloadSessions();
return;
}
try {
if (result && result.restarted) {
try {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem('commands-store');
}
} catch (storageError) {
console.warn('Failed to reset commands-store cache:', storageError);
}
await refreshAfterOpenCodeRestart({ message: 'Refreshing OpenCode configuration…' });
try {
await useCommandsStore.getState().loadCommands();
try {
emitConfigChange('commands', { source: 'useCommandsStore' });
} catch (syncError) {
console.warn('Failed to emit command configuration change:', syncError);
}
} catch (commandError) {
console.warn('Failed to reload commands after directory change:', commandError);
}
} else if (showOverlay) {
finishConfigUpdate();
}
} catch (error) {
console.error('Failed to refresh configuration after directory change:', error);
if (showOverlay) {
updateConfigUpdateMessage('Failed to refresh configuration. Please reload manually.');
await new Promise((resolve) => setTimeout(resolve, 1500));
finishConfigUpdate();
}
} finally {
onComplete?.(result);
reloadSessions();
}
})();
};
const invalidateFileSearchCache = (scope?: string | null) => {
try {
useFileSearchStore.getState().invalidateDirectory(scope);
} catch (error) {
console.warn('Failed to invalidate file search cache:', error);
}
};
const getHomeDirectory = () => {
if (typeof window !== 'undefined') {
const saved = safeStorage.getItem('lastDirectory');
if (saved) return saved;
if (cachedHomeDirectory) return cachedHomeDirectory;
const desktopHome =
(typeof window.__OPENCHAMBER_HOME__ === 'string' && window.__OPENCHAMBER_HOME__.length > 0
? window.__OPENCHAMBER_HOME__
: window.opencodeDesktop && typeof window.opencodeDesktop.homeDirectory === 'string'
? window.opencodeDesktop.homeDirectory
: null);
if (desktopHome && desktopHome.length > 0) {
cachedHomeDirectory = desktopHome;
safeStorage.setItem('homeDirectory', desktopHome);
return desktopHome;
}
const storedHome = safeStorage.getItem('homeDirectory');
if (storedHome) {
cachedHomeDirectory = storedHome;
return storedHome;
}
}
const nodeHome = typeof process !== 'undefined' && process?.env?.HOME;
if (nodeHome) {
return nodeHome;
}
return process?.cwd?.() || '/';
};
const normalizeHomeCandidate = (value?: string | null) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/\\/g, '/');
if (normalized.length > 1) {
const withoutTrailingSlash = normalized.replace(/\/+$/, '');
if (withoutTrailingSlash && withoutTrailingSlash.length > 0) {
if (withoutTrailingSlash === '/') {
return null;
}
return withoutTrailingSlash;
}
}
if (normalized === '/' || normalized.length === 0) {
return null;
}
return normalized;
};
const persistResolvedHome = (resolved: string) => {
cachedHomeDirectory = resolved;
if (typeof window !== 'undefined') {
safeStorage.setItem('homeDirectory', resolved);
}
void updateDesktopSettings({ homeDirectory: resolved });
return resolved;
};
const initializeHomeDirectory = async () => {
const acceptCandidate = (candidate?: string | null) => {
const normalized = normalizeHomeCandidate(candidate);
return normalized ? persistResolvedHome(normalized) : null;
};
try {
const fsHome = await opencodeClient.getFilesystemHome();
const resolved = acceptCandidate(fsHome);
if (resolved) {
return resolved;
}
} catch (filesystemError) {
console.warn('Failed to obtain filesystem home directory:', filesystemError);
}
try {
const info = await opencodeClient.getSystemInfo();
const resolved = acceptCandidate(info?.homeDirectory);
if (resolved) {
return resolved;
}
} catch (error) {
console.warn('Failed to get home directory from system info:', error);
}
try {
const desktopHome = await getDesktopHomeDirectory();
const resolved = acceptCandidate(desktopHome);
if (resolved) {
return resolved;
}
} catch (desktopError) {
console.warn('Failed to obtain desktop-integrated home directory:', desktopError);
}
const fallback = getHomeDirectory();
const resolvedFallback = acceptCandidate(fallback);
if (resolvedFallback) {
return resolvedFallback;
}
return fallback;
};
const initialHomeDirectory = getHomeDirectory();
if (initialHomeDirectory) {
opencodeClient.setDirectory(initialHomeDirectory);
}
const initialIsHomeReady = Boolean(initialHomeDirectory && initialHomeDirectory !== '/');
export const useDirectoryStore = create<DirectoryStore>()(
devtools(
(set, get) => ({
currentDirectory: initialHomeDirectory,
directoryHistory: [initialHomeDirectory],
historyIndex: 0,
homeDirectory: initialHomeDirectory,
hasPersistedDirectory: initialHasPersistedDirectory,
isHomeReady: initialIsHomeReady,
isSwitchingDirectory: false,
setDirectory: (path: string, options?: { showOverlay?: boolean }) => {
console.log('[DirectoryStore] setDirectory called with path:', path);
const showOverlay = options?.showOverlay ?? true;
opencodeClient.setDirectory(path);
invalidateFileSearchCache();
const restartPromise = notifyOpenCodeWorkingDirectory(path, { showOverlay });
console.log('[DirectoryStore] notifyOpenCodeWorkingDirectory initiated');
set((state) => {
const newHistory = [...state.directoryHistory.slice(0, state.historyIndex + 1), path];
safeStorage.setItem('lastDirectory', path);
void updateDesktopSettings({ lastDirectory: path });
return {
currentDirectory: path,
directoryHistory: newHistory,
historyIndex: newHistory.length - 1,
hasPersistedDirectory: true,
isHomeReady: true,
isSwitchingDirectory: true,
};
});
scheduleDirectoryFollowUp(restartPromise, { showOverlay }, () => {
set((state) => {
if (state.currentDirectory !== path) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
});
},
goBack: () => {
const state = get();
if (state.historyIndex > 0) {
const newIndex = state.historyIndex - 1;
const newDirectory = state.directoryHistory[newIndex];
opencodeClient.setDirectory(newDirectory);
invalidateFileSearchCache();
const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory);
safeStorage.setItem('lastDirectory', newDirectory);
void updateDesktopSettings({ lastDirectory: newDirectory });
set({
currentDirectory: newDirectory,
historyIndex: newIndex,
hasPersistedDirectory: true,
isHomeReady: true,
isSwitchingDirectory: true,
});
scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => {
set((state) => {
if (state.currentDirectory !== newDirectory) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
});
}
},
goForward: () => {
const state = get();
if (state.historyIndex < state.directoryHistory.length - 1) {
const newIndex = state.historyIndex + 1;
const newDirectory = state.directoryHistory[newIndex];
opencodeClient.setDirectory(newDirectory);
invalidateFileSearchCache();
const restartPromise = notifyOpenCodeWorkingDirectory(newDirectory);
safeStorage.setItem('lastDirectory', newDirectory);
void updateDesktopSettings({ lastDirectory: newDirectory });
set({
currentDirectory: newDirectory,
historyIndex: newIndex,
hasPersistedDirectory: true,
isHomeReady: true,
isSwitchingDirectory: true,
});
scheduleDirectoryFollowUp(restartPromise, { showOverlay: true }, () => {
set((state) => {
if (state.currentDirectory !== newDirectory) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
});
}
},
goToParent: () => {
const { currentDirectory, setDirectory } = get();
const homeDir = cachedHomeDirectory || get().homeDirectory || getHomeDirectory();
if (currentDirectory === homeDir || currentDirectory === '/') {
return;
}
const cleanPath = currentDirectory.endsWith('/')
? currentDirectory.slice(0, -1)
: currentDirectory;
const lastSlash = cleanPath.lastIndexOf('/');
if (lastSlash === -1) {
const home = cachedHomeDirectory || getHomeDirectory();
setDirectory(home);
} else if (lastSlash === 0) {
setDirectory('/');
} else {
setDirectory(cleanPath.substring(0, lastSlash));
}
},
goHome: async () => {
const homeDir =
cachedHomeDirectory ||
get().homeDirectory ||
(await initializeHomeDirectory());
get().setDirectory(homeDir);
},
synchronizeHomeDirectory: (homePath: string) => {
const state = get();
const resolvedHome = homePath;
cachedHomeDirectory = resolvedHome;
const needsUpdate = state.homeDirectory !== resolvedHome;
const savedLastDirectory = safeStorage.getItem('lastDirectory');
const hasSavedLastDirectory = typeof savedLastDirectory === 'string' && savedLastDirectory.length > 0;
const shouldReplaceCurrent =
!hasSavedLastDirectory &&
(
state.currentDirectory === '/' ||
state.currentDirectory === state.homeDirectory ||
!state.currentDirectory
);
if (!needsUpdate && !shouldReplaceCurrent) {
if (!state.isHomeReady) {
set({ isHomeReady: true });
}
return;
}
const resolvedReady = typeof resolvedHome === 'string' && resolvedHome !== '' && resolvedHome !== '/';
const updates: Partial<DirectoryStore> = {
homeDirectory: resolvedHome,
hasPersistedDirectory: hasSavedLastDirectory,
isHomeReady: resolvedReady
};
if (shouldReplaceCurrent) {
updates.currentDirectory = resolvedHome;
updates.directoryHistory = [resolvedHome];
updates.historyIndex = 0;
updates.isSwitchingDirectory = true;
}
set(() => updates as Partial<DirectoryStore>);
if (shouldReplaceCurrent && resolvedReady) {
opencodeClient.setDirectory(resolvedHome);
invalidateFileSearchCache();
safeStorage.setItem('lastDirectory', resolvedHome);
void updateDesktopSettings({ lastDirectory: resolvedHome });
const restartPromise = notifyOpenCodeWorkingDirectory(resolvedHome, { showOverlay: false });
scheduleDirectoryFollowUp(restartPromise, { showOverlay: false }, () => {
set((state) => {
if (state.currentDirectory !== resolvedHome) {
return {};
}
if (!state.isSwitchingDirectory) {
return {};
}
return { isSwitchingDirectory: false };
});
});
}
void updateDesktopSettings({ homeDirectory: resolvedHome });
}
}),
{
name: 'directory-store'
}
)
);
if (typeof window !== 'undefined') {
initializeHomeDirectory().then((home) => {
useDirectoryStore.getState().synchronizeHomeDirectory(home);
});
}
@@ -0,0 +1,129 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient, type ProjectFileSearchHit } from '@/lib/opencode/client';
const CACHE_TTL_MS = 30_000;
const MAX_CACHE_ENTRIES = 40;
const DEFAULT_SEARCH_LIMIT = 60;
interface FileSearchCacheEntry {
files: ProjectFileSearchHit[];
timestamp: number;
}
interface FileSearchStoreState {
cache: Record<string, FileSearchCacheEntry>;
cacheKeys: string[];
inFlight: Record<string, Promise<ProjectFileSearchHit[]>>;
searchFiles: (directory: string, query: string, limit?: number) => Promise<ProjectFileSearchHit[]>;
invalidateDirectory: (directory?: string | null) => void;
}
const buildCacheKey = (directory: string, query: string, limit: number) => {
const normalizedDirectory = directory.trim();
const normalizedQuery = query.trim().toLowerCase();
return `${normalizedDirectory}::${normalizedQuery}::${limit}`;
};
export const useFileSearchStore = create<FileSearchStoreState>()(
devtools(
(set, get) => ({
cache: {},
cacheKeys: [],
inFlight: {},
async searchFiles(directory, query, limit = DEFAULT_SEARCH_LIMIT) {
if (!directory || directory.trim().length === 0) {
return [];
}
const normalizedDirectory = directory.trim();
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
const key = buildCacheKey(normalizedDirectory, normalizedQuery, limit);
const now = Date.now();
const cached = get().cache[key];
if (cached && now - cached.timestamp < CACHE_TTL_MS) {
return cached.files;
}
const inflight = get().inFlight[key];
if (inflight) {
return inflight;
}
const searchPromise = opencodeClient
.searchFiles(normalizedQuery, { directory: normalizedDirectory, limit })
.then((files) => {
set((state) => {
const nextCache = { ...state.cache, [key]: { files, timestamp: Date.now() } };
const nextKeys = state.cacheKeys.filter((cacheKey) => cacheKey !== key);
nextKeys.push(key);
while (nextKeys.length > MAX_CACHE_ENTRIES) {
const oldestKey = nextKeys.shift();
if (oldestKey) {
delete nextCache[oldestKey];
}
}
return {
cache: nextCache,
cacheKeys: nextKeys,
};
});
return files;
})
.finally(() => {
set((state) => {
const nextInFlight = { ...state.inFlight };
delete nextInFlight[key];
return { inFlight: nextInFlight };
});
});
set((state) => ({
inFlight: {
...state.inFlight,
[key]: searchPromise,
},
}));
return searchPromise;
},
invalidateDirectory(directory) {
if (!directory || directory.trim().length === 0) {
set({ cache: {}, cacheKeys: [], inFlight: {} });
return;
}
const normalizedDirectory = directory.trim();
const prefix = `${normalizedDirectory}::`;
set((state) => {
const nextCache = { ...state.cache };
const nextKeys = state.cacheKeys.filter((cacheKey) => {
if (cacheKey.startsWith(prefix)) {
delete nextCache[cacheKey];
return false;
}
return true;
});
const nextInFlightEntries = Object.entries(state.inFlight).filter(
([key]) => !key.startsWith(prefix)
);
const nextInFlight = Object.fromEntries(nextInFlightEntries);
return {
cache: nextCache,
cacheKeys: nextKeys,
inFlight: nextInFlight,
};
});
},
}),
{
name: 'file-search-store',
}
)
);
@@ -0,0 +1,180 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { getSafeStorage } from "./utils/safeStorage";
import {
getGitIdentities,
createGitIdentity,
updateGitIdentity,
deleteGitIdentity,
getCurrentGitIdentity
} from "@/lib/gitApi";
export interface GitIdentityProfile {
id: string;
name: string;
userName: string;
userEmail: string;
sshKey?: string | null;
color?: string | null;
icon?: string | null;
}
interface GitIdentitiesStore {
selectedProfileId: string | null;
profiles: GitIdentityProfile[];
globalIdentity: GitIdentityProfile | null;
isLoading: boolean;
setSelectedProfile: (id: string | null) => void;
loadProfiles: () => Promise<boolean>;
loadGlobalIdentity: () => Promise<boolean>;
createProfile: (profile: Omit<GitIdentityProfile, 'id'> & { id?: string }) => Promise<boolean>;
updateProfile: (id: string, updates: Partial<GitIdentityProfile>) => Promise<boolean>;
deleteProfile: (id: string) => Promise<boolean>;
getProfileById: (id: string) => GitIdentityProfile | undefined;
}
declare global {
interface Window {
__zustand_git_identities_store__?: UseBoundStore<StoreApi<GitIdentitiesStore>>;
}
}
export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
devtools(
persist(
(set, get) => ({
selectedProfileId: null,
profiles: [],
globalIdentity: null,
isLoading: false,
setSelectedProfile: (id: string | null) => {
set({ selectedProfileId: id });
},
loadProfiles: async () => {
set({ isLoading: true });
const previousProfiles = get().profiles;
try {
const profiles = await getGitIdentities();
set({ profiles, isLoading: false });
return true;
} catch (error) {
console.error("Failed to load git identity profiles:", error);
set({ profiles: previousProfiles, isLoading: false });
return false;
}
},
loadGlobalIdentity: async () => {
try {
const data = await getCurrentGitIdentity('');
if (data && data.userName && data.userEmail) {
const globalProfile: GitIdentityProfile = {
id: 'global',
name: 'Global Identity',
userName: data.userName,
userEmail: data.userEmail,
sshKey: data.sshCommand ? data.sshCommand.replace('ssh -i ', '') : null,
color: 'info',
icon: 'house'
};
set({ globalIdentity: globalProfile });
} else {
set({ globalIdentity: null });
}
return true;
} catch (error) {
console.error("Failed to load global git identity:", error);
set({ globalIdentity: null });
return false;
}
},
createProfile: async (profileData) => {
try {
const profile = {
...profileData,
id: profileData.id || `profile-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
color: profileData.color || 'keyword',
icon: profileData.icon || 'branch'
};
await createGitIdentity(profile);
await get().loadProfiles();
return true;
} catch (error) {
console.error("Failed to create git identity profile:", error);
return false;
}
},
updateProfile: async (id, updates) => {
try {
const existing = get().profiles.find(p => p.id === id);
if (!existing) {
throw new Error("Profile not found");
}
const updated = { ...existing, ...updates };
await updateGitIdentity(id, updated);
await get().loadProfiles();
return true;
} catch (error) {
console.error("Failed to update git identity profile:", error);
return false;
}
},
deleteProfile: async (id) => {
try {
await deleteGitIdentity(id);
if (get().selectedProfileId === id) {
set({ selectedProfileId: null });
}
await get().loadProfiles();
return true;
} catch (error) {
console.error("Failed to delete git identity profile:", error);
return false;
}
},
getProfileById: (id) => {
const { profiles, globalIdentity } = get();
if (id === 'global') {
return globalIdentity || undefined;
}
return profiles.find((p) => p.id === id);
},
}),
{
name: "git-identities-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
selectedProfileId: state.selectedProfileId,
}),
},
),
{
name: "git-identities-store",
},
),
);
if (typeof window !== "undefined") {
window.__zustand_git_identities_store__ = useGitIdentitiesStore;
}
+391
View File
@@ -0,0 +1,391 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
GitStatus,
GitBranch,
GitLogResponse,
GitIdentitySummary,
} from '@/lib/api/types';
const GIT_POLL_INTERVAL = 3000;
const LOG_STALE_THRESHOLD = 30000;
interface DirectoryGitState {
isGitRepo: boolean | null;
status: GitStatus | null;
branches: GitBranch | null;
log: GitLogResponse | null;
identity: GitIdentitySummary | null;
diffCache: Map<string, { original: string; modified: string; fetchedAt: number }>;
lastStatusFetch: number;
lastStatusChange: number;
lastLogFetch: number;
logMaxCount: number;
}
interface GitStore {
directories: Map<string, DirectoryGitState>;
activeDirectory: string | null;
isLoadingStatus: boolean;
isLoadingLog: boolean;
isLoadingBranches: boolean;
isLoadingIdentity: boolean;
pollIntervalId: ReturnType<typeof setInterval> | null;
setActiveDirectory: (directory: string | null) => void;
getDirectoryState: (directory: string) => DirectoryGitState | null;
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean }) => Promise<boolean>;
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean }) => Promise<void>;
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string }) => void;
clearDiffCache: (directory: string) => void;
setLogMaxCount: (directory: string, maxCount: number) => void;
startPolling: (git: GitAPI) => void;
stopPolling: () => void;
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
}
interface GitAPI {
checkIsGitRepository: (directory: string) => Promise<boolean>;
getGitStatus: (directory: string) => Promise<GitStatus>;
getGitBranches: (directory: string) => Promise<GitBranch>;
getGitLog: (directory: string, options?: { maxCount?: number }) => Promise<GitLogResponse>;
getCurrentGitIdentity: (directory: string) => Promise<GitIdentitySummary | null>;
}
const createEmptyDirectoryState = (): DirectoryGitState => ({
isGitRepo: null,
status: null,
branches: null,
log: null,
identity: null,
diffCache: new Map(),
lastStatusFetch: 0,
lastStatusChange: 0,
lastLogFetch: 0,
logMaxCount: 25,
});
const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | null): boolean => {
if (!oldStatus && !newStatus) return false;
if (!oldStatus || !newStatus) return true;
const oldFiles = oldStatus.files ?? [];
const newFiles = newStatus.files ?? [];
if (oldFiles.length !== newFiles.length) return true;
if (oldStatus.ahead !== newStatus.ahead) return true;
if (oldStatus.behind !== newStatus.behind) return true;
if (oldStatus.current !== newStatus.current) return true;
const oldPaths = new Set(oldFiles.map(f => `${f.path}:${f.index}:${f.working_dir}`));
for (const file of newFiles) {
if (!oldPaths.has(`${file.path}:${file.index}:${file.working_dir}`)) {
return true;
}
}
return false;
};
export const useGitStore = create<GitStore>()(
devtools(
(set, get) => ({
directories: new Map(),
activeDirectory: null,
isLoadingStatus: false,
isLoadingLog: false,
isLoadingBranches: false,
isLoadingIdentity: false,
pollIntervalId: null,
setActiveDirectory: (directory) => {
const { activeDirectory, directories } = get();
if (activeDirectory === directory) return;
if (directory && !directories.has(directory)) {
const newDirectories = new Map(directories);
newDirectories.set(directory, createEmptyDirectoryState());
set({ activeDirectory: directory, directories: newDirectories });
} else {
set({ activeDirectory: directory });
}
},
getDirectoryState: (directory) => {
return get().directories.get(directory) ?? null;
},
fetchStatus: async (directory, git, options = {}) => {
const { silent = false } = options;
const { directories } = get();
let dirState = directories.get(directory);
if (!dirState) {
dirState = createEmptyDirectoryState();
}
if (!silent) {
set({ isLoadingStatus: true });
}
let statusChanged = false;
try {
const isRepo = await git.checkIsGitRepository(directory);
if (!isRepo) {
const newDirectories = new Map(directories);
newDirectories.set(directory, {
...dirState,
isGitRepo: false,
status: null,
lastStatusFetch: Date.now(),
});
set({ directories: newDirectories, isLoadingStatus: false });
return false;
}
const newStatus = await git.getGitStatus(directory);
if (hasStatusChanged(dirState.status, newStatus)) {
statusChanged = true;
const newDirectories = new Map(get().directories);
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, {
...currentDirState,
isGitRepo: true,
status: newStatus,
diffCache: new Map(),
lastStatusFetch: Date.now(),
lastStatusChange: Date.now(),
});
set({ directories: newDirectories });
} else {
const newDirectories = new Map(get().directories);
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, {
...currentDirState,
isGitRepo: true,
lastStatusFetch: Date.now(),
lastStatusChange: currentDirState.lastStatusChange,
});
set({ directories: newDirectories });
}
} catch (error) {
console.error('Failed to fetch git status:', error);
} finally {
if (!silent) {
set({ isLoadingStatus: false });
}
}
return statusChanged;
},
fetchBranches: async (directory, git) => {
set({ isLoadingBranches: true });
try {
const branches = await git.getGitBranches(directory);
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...dirState, branches });
set({ directories: newDirectories });
} catch (error) {
console.error('Failed to fetch git branches:', error);
} finally {
set({ isLoadingBranches: false });
}
},
fetchLog: async (directory, git, maxCount) => {
const { directories } = get();
const dirState = directories.get(directory);
const effectiveMaxCount = maxCount ?? dirState?.logMaxCount ?? 25;
set({ isLoadingLog: true });
try {
const log = await git.getGitLog(directory, { maxCount: effectiveMaxCount });
const newDirectories = new Map(get().directories);
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, {
...currentDirState,
log,
lastLogFetch: Date.now(),
logMaxCount: effectiveMaxCount,
});
set({ directories: newDirectories });
} catch (error) {
console.error('Failed to fetch git log:', error);
} finally {
set({ isLoadingLog: false });
}
},
fetchIdentity: async (directory, git) => {
set({ isLoadingIdentity: true });
try {
const identity = await git.getCurrentGitIdentity(directory);
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...dirState, identity });
set({ directories: newDirectories });
} catch (error) {
console.error('Failed to fetch git identity:', error);
} finally {
set({ isLoadingIdentity: false });
}
},
fetchAll: async (directory, git, options = {}) => {
const { directories } = get();
let dirState = directories.get(directory);
if (!dirState) {
dirState = createEmptyDirectoryState();
const newDirectories = new Map(directories);
newDirectories.set(directory, dirState);
set({ directories: newDirectories });
}
const { force = false } = options;
const now = Date.now();
await get().fetchStatus(directory, git);
const updatedDirState = get().directories.get(directory);
if (!updatedDirState?.isGitRepo) return;
await get().fetchBranches(directory, git);
const logAge = now - (updatedDirState.lastLogFetch || 0);
if (force || logAge > LOG_STALE_THRESHOLD || !updatedDirState.log) {
await get().fetchLog(directory, git);
}
await get().fetchIdentity(directory, git);
},
getDiff: (directory, filePath) => {
const dirState = get().directories.get(directory);
return dirState?.diffCache.get(filePath) ?? null;
},
setDiff: (directory, filePath, diff) => {
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
const newDiffCache = new Map(dirState.diffCache);
newDiffCache.set(filePath, { ...diff, fetchedAt: Date.now() });
newDirectories.set(directory, { ...dirState, diffCache: newDiffCache });
set({ directories: newDirectories });
},
clearDiffCache: (directory) => {
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory);
if (dirState) {
newDirectories.set(directory, { ...dirState, diffCache: new Map() });
set({ directories: newDirectories });
}
},
setLogMaxCount: (directory, maxCount) => {
const newDirectories = new Map(get().directories);
const dirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
newDirectories.set(directory, { ...dirState, logMaxCount: maxCount });
set({ directories: newDirectories });
},
startPolling: (git) => {
const { pollIntervalId } = get();
if (pollIntervalId) return;
const intervalId = setInterval(async () => {
const { activeDirectory } = get();
if (!activeDirectory) return;
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
if (statusChanged) {
await get().fetchLog(activeDirectory, git);
}
}, GIT_POLL_INTERVAL);
set({ pollIntervalId: intervalId });
},
stopPolling: () => {
const { pollIntervalId } = get();
if (pollIntervalId) {
clearInterval(pollIntervalId);
set({ pollIntervalId: null });
}
},
refresh: async (git, options = {}) => {
const { activeDirectory } = get();
if (!activeDirectory) return;
await get().fetchAll(activeDirectory, git, options);
},
}),
{ name: 'git-store' }
)
);
export const useGitStatus = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return null;
return state.directories.get(directory)?.status ?? null;
});
};
export const useGitBranches = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return null;
return state.directories.get(directory)?.branches ?? null;
});
};
export const useGitLog = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return null;
return state.directories.get(directory)?.log ?? null;
});
};
export const useGitIdentity = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return null;
return state.directories.get(directory)?.identity ?? null;
});
};
export const useIsGitRepo = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return null;
return state.directories.get(directory)?.isGitRepo ?? null;
});
};
export const useGitFileCount = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return 0;
return state.directories.get(directory)?.status?.files?.length ?? 0;
});
};
+453
View File
@@ -0,0 +1,453 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools } from "zustand/middleware";
import type { Session, Message, Part } from "@opencode-ai/sdk";
import type { Permission, PermissionResponse } from "@/types/permission";
import type { SessionStore, AttachedFile, EditPermissionMode } from "./types/sessionTypes";
import { ACTIVE_SESSION_WINDOW, MEMORY_LIMITS } 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 { opencodeClient } from "@/lib/opencode/client";
import { useDirectoryStore } from "./useDirectoryStore";
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 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: [],
currentSessionId: null,
lastLoadedDirectory: null,
messages: new Map(),
sessionMemoryState: new Map(),
messageStreamStates: new Map(),
sessionCompactionUntil: new Map(),
sessionAbortFlags: new Map(),
permissions: 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: [],
currentAgentContext: new Map(),
sessionContextUsage: new Map(),
sessionAgentEditModes: new Map(),
abortPromptSessionId: null,
abortPromptExpiresAt: null,
sessionActivityPhase: new Map(),
userSummaryTitles: new Map(),
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(),
createSession: async (title?: string, directoryOverride?: string | null) => {
const result = await useSessionManagementStore.getState().createSession(title, directoryOverride);
if (result?.id) {
await get().setCurrentSession(result.id);
}
return result;
},
deleteSession: (id: string, options) => useSessionManagementStore.getState().deleteSession(id, options),
deleteSessions: (ids: string[], options) => useSessionManagementStore.getState().deleteSessions(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) => {
const previousSessionId = get().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);
}
get().trimToViewportWindow(previousSessionId, MEMORY_LIMITS.VIEWPORT_MESSAGES);
}
}
useSessionManagementStore.getState().setCurrentSession(id);
if (id) {
const existingMessages = get().messages.get(id);
if (!existingMessages) {
await get().loadMessages(id);
}
get().trimToViewportWindow(id, ACTIVE_SESSION_WINDOW);
}
get().evictLeastRecentlyUsed();
},
loadMessages: (sessionId: string) => useMessageStore.getState().loadMessages(sessionId),
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string) => {
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
return useMessageStore.getState().sendMessage(content, providerID, modelID, agent, currentSessionId || undefined, attachments, agentMentionName);
},
abortCurrentOperation: () => {
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
return useMessageStore.getState().abortCurrentOperation(currentSessionId || 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);
},
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: Permission) => {
const contextData = {
currentAgentContext: useContextStore.getState().currentAgentContext,
sessionAgentSelections: useContextStore.getState().sessionAgentSelections,
getSessionAgentEditMode: useContextStore.getState().getSessionAgentEditMode,
};
return usePermissionStore.getState().addPermission(permission, contextData);
},
respondToPermission: (sessionId: string, permissionId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, permissionId, response),
clearError: () => useSessionManagementStore.getState().clearError(),
getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory),
getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId),
getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId),
syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => useMessageStore.getState().syncMessages(sessionId, messages),
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),
trimToViewportWindow: (sessionId: string, targetSize?: number) => {
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
return useMessageStore.getState().trimToViewportWindow(sessionId, targetSize, currentSessionId || undefined);
},
evictLeastRecentlyUsed: () => {
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
return useMessageStore.getState().evictLeastRecentlyUsed(currentSessionId || undefined);
},
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),
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) => {
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 = 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),
}),
{
name: "composed-session-store",
}
),
);
useSessionManagementStore.subscribe((state, prevState) => {
if (
state.sessions === prevState.sessions &&
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
) {
return;
}
useSessionStore.setState({
sessions: state.sessions,
currentSessionId: state.currentSessionId,
lastLoadedDirectory: state.lastLoadedDirectory,
isLoading: state.isLoading,
error: state.error,
webUICreatedSessions: state.webUICreatedSessions,
worktreeMetadata: state.worktreeMetadata,
availableWorktrees: state.availableWorktrees,
});
});
useMessageStore.subscribe((state, prevState) => {
if (
state.messages === prevState.messages &&
state.sessionMemoryState === prevState.sessionMemoryState &&
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;
}
const userSummaryTitles = new Map<string, { title: string; createdAt: number | null }>();
state.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({
messages: state.messages,
sessionMemoryState: state.sessionMemoryState,
messageStreamStates: state.messageStreamStates,
sessionCompactionUntil: state.sessionCompactionUntil,
sessionAbortFlags: state.sessionAbortFlags,
streamingMessageIds: state.streamingMessageIds,
abortControllers: state.abortControllers,
lastUsedProvider: state.lastUsedProvider,
isSyncing: state.isSyncing,
userSummaryTitles,
});
});
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,
});
});
useSessionStore.setState({
sessions: useSessionManagementStore.getState().sessions,
currentSessionId: 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,
messages: useMessageStore.getState().messages,
sessionMemoryState: useMessageStore.getState().sessionMemoryState,
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,
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;
}
+175
View File
@@ -0,0 +1,175 @@
import { create } from 'zustand';
import type { TerminalSession } from '@/lib/terminalApi';
export interface TerminalChunk {
id: number;
data: string;
}
interface TerminalSessionState {
sessionId: string;
terminalSessionId: string | null;
directory: string;
isConnecting: boolean;
buffer: string;
bufferChunks: TerminalChunk[];
bufferLength: number;
updatedAt: number;
}
interface TerminalStore {
sessions: Map<string, TerminalSessionState>;
nextChunkId: number;
getTerminalSession: (sessionId: string) => TerminalSessionState | undefined;
setTerminalSession: (sessionId: string, terminalSession: TerminalSession, directory: string) => void;
setConnecting: (sessionId: string, isConnecting: boolean) => void;
appendToBuffer: (sessionId: string, chunk: string) => void;
clearTerminalSession: (sessionId: string) => void;
clearBuffer: (sessionId: string) => void;
removeTerminalSession: (sessionId: string) => void;
clearAllTerminalSessions: () => void;
}
const TERMINAL_BUFFER_LIMIT = 60_000;
const createEmptySessionState = (sessionId: string): TerminalSessionState => ({
sessionId,
terminalSessionId: null,
directory: '',
isConnecting: false,
buffer: '',
bufferChunks: [],
bufferLength: 0,
updatedAt: Date.now(),
});
export const useTerminalStore = create<TerminalStore>((set, get) => ({
sessions: new Map(),
nextChunkId: 1,
getTerminalSession: (sessionId: string) => {
return get().sessions.get(sessionId);
},
setTerminalSession: (sessionId: string, terminalSession: TerminalSession, directory: string) => {
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(sessionId);
const shouldResetBuffer =
!existing ||
existing.terminalSessionId !== terminalSession.sessionId ||
existing.directory !== directory;
const baseState = shouldResetBuffer
? createEmptySessionState(sessionId)
: existing ?? createEmptySessionState(sessionId);
newSessions.set(sessionId, {
...baseState,
terminalSessionId: terminalSession.sessionId,
directory,
isConnecting: false,
updatedAt: Date.now(),
});
return { sessions: newSessions };
});
},
setConnecting: (sessionId: string, isConnecting: boolean) => {
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(sessionId) ?? createEmptySessionState(sessionId);
newSessions.set(sessionId, {
...existing,
isConnecting,
updatedAt: Date.now(),
});
return { sessions: newSessions };
});
},
appendToBuffer: (sessionId: string, chunk: string) => {
if (!chunk) {
return;
}
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(sessionId) ?? createEmptySessionState(sessionId);
const chunkId = state.nextChunkId;
const chunkEntry: TerminalChunk = { id: chunkId, data: chunk };
const bufferChunks = [...existing.bufferChunks, chunkEntry];
let bufferLength = existing.bufferLength + chunk.length;
while (bufferLength > TERMINAL_BUFFER_LIMIT && bufferChunks.length > 1) {
const removed = bufferChunks.shift();
if (!removed) {
break;
}
bufferLength -= removed.data.length;
}
const buffer = bufferChunks.map((entry) => entry.data).join('');
newSessions.set(sessionId, {
...existing,
buffer,
bufferChunks,
bufferLength,
updatedAt: Date.now(),
});
return { sessions: newSessions, nextChunkId: chunkId + 1 };
});
},
clearTerminalSession: (sessionId: string) => {
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(sessionId);
if (existing) {
newSessions.set(sessionId, {
...existing,
terminalSessionId: null,
isConnecting: false,
updatedAt: Date.now(),
});
}
return { sessions: newSessions };
});
},
clearBuffer: (sessionId: string) => {
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(sessionId);
if (!existing) {
return state;
}
newSessions.set(sessionId, {
...existing,
buffer: '',
bufferChunks: [],
bufferLength: 0,
updatedAt: Date.now(),
});
return { sessions: newSessions };
});
},
removeTerminalSession: (sessionId: string) => {
set((state) => {
const newSessions = new Map(state.sessions);
newSessions.delete(sessionId);
return { sessions: newSessions };
});
},
clearAllTerminalSessions: () => {
set({ sessions: new Map(), nextChunkId: 1 });
},
}));
+258
View File
@@ -0,0 +1,258 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import type { SidebarSection } from '@/constants/sidebar';
import { getSafeStorage } from './utils/safeStorage';
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal';
export type EventStreamStatus =
| 'idle'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'paused'
| 'offline'
| 'error';
interface UIStore {
theme: 'light' | 'dark' | 'system';
isSidebarOpen: boolean;
sidebarWidth: number;
hasManuallyResizedLeftSidebar: boolean;
isSessionSwitcherOpen: boolean;
activeMainTab: MainTab;
pendingDiffFile: string | null;
isMobile: boolean;
isCommandPaletteOpen: boolean;
isHelpDialogOpen: boolean;
isSessionCreateDialogOpen: boolean;
isSettingsDialogOpen: boolean;
sidebarSection: SidebarSection;
eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null;
showReasoningTraces: boolean;
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSidebarWidth: (width: number) => void;
setSessionSwitcherOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
setPendingDiffFile: (filePath: string | null) => void;
navigateToDiff: (filePath: string) => void;
consumePendingDiffFile: () => string | null;
setIsMobile: (isMobile: boolean) => void;
toggleCommandPalette: () => void;
setCommandPaletteOpen: (open: boolean) => void;
toggleHelpDialog: () => void;
setHelpDialogOpen: (open: boolean) => void;
setSessionCreateDialogOpen: (open: boolean) => void;
setSettingsDialogOpen: (open: boolean) => void;
applyTheme: () => void;
setSidebarSection: (section: SidebarSection) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void;
updateProportionalSidebarWidths: () => void;
setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void;
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
}
export const useUIStore = create<UIStore>()(
devtools(
persist(
(set, get) => ({
theme: 'system',
isSidebarOpen: true,
sidebarWidth: 264,
hasManuallyResizedLeftSidebar: false,
isSessionSwitcherOpen: false,
activeMainTab: 'chat',
pendingDiffFile: null,
isMobile: false,
isCommandPaletteOpen: false,
isHelpDialogOpen: false,
isSessionCreateDialogOpen: false,
isSettingsDialogOpen: false,
sidebarSection: 'sessions',
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: false,
diffLayoutPreference: 'dynamic',
diffFileLayout: {},
setTheme: (theme) => {
set({ theme });
get().applyTheme();
},
toggleSidebar: () => {
set((state) => {
const newOpen = !state.isSidebarOpen;
if (newOpen && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
return {
isSidebarOpen: newOpen,
sidebarWidth: proportionalWidth,
hasManuallyResizedLeftSidebar: false
};
}
return { isSidebarOpen: newOpen };
});
},
setSidebarOpen: (open) => {
set(() => {
if (open && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
return {
isSidebarOpen: open,
sidebarWidth: proportionalWidth,
hasManuallyResizedLeftSidebar: false
};
}
return { isSidebarOpen: open };
});
},
setSidebarWidth: (width) => {
set({ sidebarWidth: width, hasManuallyResizedLeftSidebar: true });
},
setSessionSwitcherOpen: (open) => {
set({ isSessionSwitcherOpen: open });
},
setActiveMainTab: (tab) => {
set({ activeMainTab: tab });
},
setPendingDiffFile: (filePath) => {
set({ pendingDiffFile: filePath });
},
navigateToDiff: (filePath) => {
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
},
consumePendingDiffFile: () => {
const { pendingDiffFile } = get();
if (pendingDiffFile) {
set({ pendingDiffFile: null });
}
return pendingDiffFile;
},
setIsMobile: (isMobile) => {
set({ isMobile });
},
toggleCommandPalette: () => {
set((state) => ({ isCommandPaletteOpen: !state.isCommandPaletteOpen }));
},
setCommandPaletteOpen: (open) => {
set({ isCommandPaletteOpen: open });
},
toggleHelpDialog: () => {
set((state) => ({ isHelpDialogOpen: !state.isHelpDialogOpen }));
},
setHelpDialogOpen: (open) => {
set({ isHelpDialogOpen: open });
},
setSessionCreateDialogOpen: (open) => {
set({ isSessionCreateDialogOpen: open });
},
setSettingsDialogOpen: (open) => {
set({ isSettingsDialogOpen: open });
},
setSidebarSection: (section) => {
set({ sidebarSection: section });
},
setEventStreamStatus: (status, hint) => {
set({
eventStreamStatus: status,
eventStreamHint: hint ?? null,
});
},
setShowReasoningTraces: (value) => {
set({ showReasoningTraces: value });
},
setDiffLayoutPreference: (mode) => {
set({ diffLayoutPreference: mode });
},
setDiffFileLayout: (filePath, mode) => {
set((state) => ({
diffFileLayout: {
...state.diffFileLayout,
[filePath]: mode,
},
}));
},
updateProportionalSidebarWidths: () => {
if (typeof window === 'undefined') {
return;
}
set((state) => {
const updates: Partial<UIStore> = {};
if (state.isSidebarOpen && !state.hasManuallyResizedLeftSidebar) {
updates.sidebarWidth = Math.floor(window.innerWidth * 0.2);
}
return updates;
});
},
applyTheme: () => {
const { theme } = get();
const root = document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
}
}),
{
name: 'ui-store',
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
theme: state.theme,
isSidebarOpen: state.isSidebarOpen,
sidebarWidth: state.sidebarWidth,
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab,
sidebarSection: state.sidebarSection,
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
isSettingsDialogOpen: state.isSettingsDialogOpen,
showReasoningTraces: state.showReasoningTraces,
diffLayoutPreference: state.diffLayoutPreference,
})
}
),
{
name: 'ui-store'
}
)
);
@@ -0,0 +1,22 @@
export const calculateContextUsage = (
totalTokens: number,
contextLimit: number,
outputLimit: number
) => {
const safeContext = Number.isFinite(contextLimit) ? Math.max(contextLimit, 0) : 0;
const hasOutputLimit = Number.isFinite(outputLimit) && outputLimit > 0;
const safeOutput = hasOutputLimit ? Math.max(outputLimit, 0) : 0;
const effectiveOutputReservation = Math.min(hasOutputLimit ? safeOutput : 32000, 32000);
const normalizedOutput = Math.min(effectiveOutputReservation, safeContext);
const thresholdLimit = safeContext > 0 ? Math.max(safeContext - normalizedOutput, 1) : 0;
const percentage = thresholdLimit > 0 ? (totalTokens / thresholdLimit) * 100 : 0;
return {
percentage: Math.min(percentage, 100),
contextLimit: safeContext,
outputLimit: safeOutput,
thresholdLimit: thresholdLimit || 1,
normalizedOutput
};
};
@@ -0,0 +1,68 @@
import type { Part } from "@opencode-ai/sdk";
const extractTextFromDelta = (delta: unknown): string => {
if (!delta) return '';
if (typeof delta === 'string') return delta;
if (Array.isArray(delta)) {
return delta.map((item) => extractTextFromDelta(item)).join('');
}
if (typeof delta === 'object') {
if (typeof (delta as { text?: unknown }).text === 'string') {
return (delta as { text: string }).text;
}
if (Array.isArray((delta as { content?: unknown[] }).content)) {
return (delta as { content: unknown[] }).content.map((item: unknown) => extractTextFromDelta(item)).join('');
}
}
return '';
};
export const extractTextFromPart = (part: unknown): string => {
if (!part) return '';
const typedPart = part as { text?: string | unknown[]; delta?: unknown; content?: string | unknown[] };
if (typeof typedPart.text === 'string') return typedPart.text;
if (Array.isArray(typedPart.text)) {
return typedPart.text.map((item: unknown) => (typeof item === 'string' ? item : extractTextFromPart(item))).join('');
}
const deltaText = extractTextFromDelta(typedPart.delta);
if (deltaText) return deltaText;
if (typeof typedPart.content === 'string') return typedPart.content;
if (Array.isArray(typedPart.content)) {
return typedPart.content
.map((item: unknown) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object') {
const typedItem = item as { text?: string; delta?: unknown };
return typedItem.text || extractTextFromDelta(typedItem.delta) || '';
}
return '';
})
.join('');
}
return '';
};
export const normalizeStreamingPart = (incoming: Part, existing?: Part): Part => {
const normalized: { type?: string; text?: string; delta?: unknown; [key: string]: unknown } = { ...incoming } as { type?: string; text?: string; delta?: unknown; [key: string]: unknown };
normalized.type = normalized.type || 'text';
if (normalized.type === 'text') {
const existingText = existing && typeof (existing as { text?: string }).text === 'string' ? (existing as { text: string }).text : '';
const directText = typeof normalized.text === 'string' ? normalized.text : '';
const deltaText = extractTextFromDelta((incoming as { delta?: unknown }).delta);
if (directText) {
normalized.text = directText;
} else if (deltaText) {
normalized.text = existingText ? `${existingText}${deltaText}` : deltaText;
} else if (existingText) {
normalized.text = existingText;
} else {
normalized.text = '';
}
delete normalized.delta;
}
return normalized as Part;
};
@@ -0,0 +1,54 @@
import type { EditPermissionMode } from "../types/sessionTypes";
const EDIT_PERMISSION_TOOL_NAMES = new Set([
'edit',
'multiedit',
'str_replace',
'str_replace_based_edit_tool',
'write',
]);
export const isEditPermissionType = (type?: string | null): boolean => {
if (!type) {
return false;
}
return EDIT_PERMISSION_TOOL_NAMES.has(type.toLowerCase());
};
const resolveConfigStore = () => {
if (typeof window === 'undefined') {
return undefined;
}
return (window as { __zustand_config_store__?: { getState?: () => { agents?: Array<{ name: string; permission?: { edit?: string }; tools?: { edit?: boolean } }> } } }).__zustand_config_store__;
};
const getAgentDefinition = (agentName?: string): { name: string; permission?: { edit?: string }; tools?: { edit?: boolean } } | undefined => {
if (!agentName) {
return undefined;
}
try {
const configStore = resolveConfigStore();
if (configStore?.getState) {
const state = configStore.getState();
return state.agents?.find?.((agent: { name: string; permission?: { edit?: string }; tools?: { edit?: boolean } }) => agent.name === agentName);
}
} catch { /* ignored */ }
return undefined;
};
export const getAgentDefaultEditPermission = (agentName?: string): EditPermissionMode => {
const agent = getAgentDefinition(agentName);
if (!agent) {
return 'ask';
}
const permission = agent.permission?.edit;
if (permission === 'allow' || permission === 'ask' || permission === 'deny' || permission === 'full') {
return permission;
}
const editToolEnabled = agent.tools ? agent.tools.edit !== false : true;
return editToolEnabled ? 'ask' : 'deny';
};
+121
View File
@@ -0,0 +1,121 @@
let safeStorageInstance: Storage | null = null;
const createInMemoryStorage = (): Storage => {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size;
},
} as Storage;
};
const createSafeStorage = (): Storage => {
if (typeof window === 'undefined' || !window.localStorage) {
return createInMemoryStorage();
}
const baseStorage = window.localStorage;
const fallback = createInMemoryStorage();
let storageAvailable = true;
const disableStorage = () => {
storageAvailable = false;
};
const safeGet = (key: string): string | null => {
if (storageAvailable) {
try {
const value = baseStorage.getItem(key);
if (value !== null) {
return value;
}
} catch {
disableStorage();
}
}
return fallback.getItem(key);
};
const safeSet = (key: string, value: string) => {
if (storageAvailable) {
try {
baseStorage.setItem(key, value);
fallback.removeItem(key);
return;
} catch {
disableStorage();
}
}
fallback.setItem(key, value);
};
const safeRemove = (key: string) => {
if (storageAvailable) {
try {
baseStorage.removeItem(key);
} catch {
disableStorage();
}
}
fallback.removeItem(key);
};
const safeClear = () => {
if (storageAvailable) {
try {
baseStorage.clear();
} catch {
disableStorage();
}
}
fallback.clear();
};
const safeKey = (index: number): string | null => {
if (storageAvailable) {
try {
return baseStorage.key(index);
} catch {
disableStorage();
}
}
return fallback.key(index);
};
return {
getItem: safeGet,
setItem: safeSet,
removeItem: safeRemove,
clear: safeClear,
key: safeKey,
get length() {
if (storageAvailable) {
try {
return baseStorage.length + fallback.length;
} catch {
disableStorage();
}
}
return fallback.length;
},
} as Storage;
};
export const getSafeStorage = (): Storage => {
if (!safeStorageInstance) {
safeStorageInstance = createSafeStorage();
}
return safeStorageInstance;
};
@@ -0,0 +1,8 @@
export const streamDebugEnabled = (): boolean => {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem('openchamber_stream_debug') === '1';
} catch {
return false;
}
};
@@ -0,0 +1,56 @@
import type { MessageStreamLifecycle } from "../types/sessionTypes";
export type { MessageStreamLifecycle };
export const touchStreamingLifecycle = (
source: Map<string, MessageStreamLifecycle>,
messageId: string
): Map<string, MessageStreamLifecycle> => {
const now = Date.now();
const existing = source.get(messageId);
const next = new Map(source);
next.set(messageId, {
phase: 'streaming',
startedAt: existing?.startedAt ?? now,
lastUpdateAt: now,
});
return next;
};
export const removeLifecycleEntries = (
source: Map<string, MessageStreamLifecycle>,
ids: Iterable<string>
): Map<string, MessageStreamLifecycle> => {
const idsArray = Array.from(ids);
const shouldClone = idsArray.some((id) => source.has(id));
if (!shouldClone) {
return source;
}
const next = new Map(source);
idsArray.forEach((id) => {
next.delete(id);
});
return next;
};
const lifecycleCompletionTimers = new Map<string, ReturnType<typeof setTimeout>>();
export const clearLifecycleCompletionTimer = (messageId: string) => {
const timer = lifecycleCompletionTimers.get(messageId);
if (timer) {
clearTimeout(timer);
lifecycleCompletionTimers.delete(messageId);
}
};
export const clearLifecycleTimersForIds = (ids: Iterable<string>) => {
for (const id of ids) {
clearLifecycleCompletionTimer(id);
}
};
@@ -0,0 +1,51 @@
import type { Message, Part } from "@opencode-ai/sdk";
type TokenBreakdown = {
input?: number;
output?: number;
reasoning?: number;
cache?: {
read?: number;
write?: number;
};
};
const sumTokenBreakdown = (breakdown: TokenBreakdown | null | undefined): number => {
if (!breakdown || typeof breakdown !== 'object') {
return 0;
}
const inputTokens = breakdown.input ?? 0;
const outputTokens = breakdown.output ?? 0;
const reasoningTokens = breakdown.reasoning ?? 0;
const cacheReadTokens = breakdown.cache && typeof breakdown.cache === 'object' ? breakdown.cache.read ?? 0 : 0;
const cacheWriteTokens = breakdown.cache && typeof breakdown.cache === 'object' ? breakdown.cache.write ?? 0 : 0;
return inputTokens + outputTokens + reasoningTokens + cacheReadTokens + cacheWriteTokens;
};
export const extractTokensFromMessage = (message: { info: Message; parts: Part[] }): number => {
const tokens = (message.info as { tokens?: number | TokenBreakdown }).tokens;
if (typeof tokens === 'number') {
return tokens;
}
if (tokens && typeof tokens === 'object') {
return sumTokenBreakdown(tokens);
}
const tokenPart = message.parts.find(
(part) => typeof (part as { tokens?: number | TokenBreakdown }).tokens !== 'undefined'
) as { tokens?: number | TokenBreakdown } | undefined;
if (!tokenPart || typeof tokenPart.tokens === 'undefined') {
return 0;
}
if (typeof tokenPart.tokens === 'number') {
return tokenPart.tokens;
}
return sumTokenBreakdown(tokenPart.tokens);
};