Initial public release
This commit is contained in:
@@ -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",
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user