Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)
## Summary Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements. ## Key Changes **Sidebar & Navigation Redesign** - Redesigned sessions sidebar layout with unified button primitives - Added activity sections with project grouping and improved session organization - Refined sidebar corners, spacing, and visual hierarchy - Removed NavRail component in favor of streamlined sidebar - Stabilized sessions bar toggle position in fullscreen mode **Performance Optimizations** - Reduced chat streaming CPU usage and storage churn - Optimized task tool polling and live timers with debouncing - Prevented chat state races and reduced background request load - Debounced draft writes and coalesced session reloads - Optimized message store updates and turn tracking **Theme & Visual System** - Added theme-aware window corners (desktop) and border radius tokens - Introduced glassmorphism effects on desktop sidebar - Added backdrop blur to UI elements **Chat Experience** - Added session-based permission auto-accept toggle in chat input - Polished permission shield UX with improved icon sizing and spacing - Fixed chat scroll-to-bottom behavior and timeline tracking - Enhanced tool output display with better path label detection - Removed duplicate draft context details in chat header - Added text selection menu to chat messages **Git Improvements** - Refreshed git history visual design with cleaner dividers - Added remote removal action in sync selector - Stabilized git polling to prevent excessive requests - Improved tool output rendering for git operations **Settings & Panels** - Fixed mobile scrolling on settings pages - Made outside-click settings close instantly - Reduced settings load churn and CPU spikes - Improved services dropdown layout and spacing - Softened panel resize handles **Desktop Integration** - Synced macOS window theme with app theme - Restored window dragging in sidebar header zones - Fixed system window corners on macOS - Improved header session metadata and action controls **Button & Component Standardization** - Unified button primitives across all components - Standardized destructive action patterns - Removed unused button variants (button-large, button-small) - Aligned context tab close hit areas --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
359879153a
commit
321cc7252a
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,27 @@
|
||||
import { create } from "zustand";
|
||||
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
||||
import { opencodeClient } from "@/lib/opencode/client";
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client";
|
||||
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
|
||||
import { isEditPermissionType, getAgentDefaultEditPermission } from "./utils/permissionUtils";
|
||||
import {
|
||||
normalizeDirectory,
|
||||
type PermissionAutoAcceptMap,
|
||||
} from "./utils/permissionAutoAccept";
|
||||
import { getSafeStorage } from "./utils/safeStorage";
|
||||
import { useMessageStore } from "./messageStore";
|
||||
import { useSessionStore } from "./sessionStore";
|
||||
|
||||
interface PermissionState {
|
||||
permissions: Map<string, PermissionRequest[]>;
|
||||
autoAccept: PermissionAutoAcceptMap;
|
||||
}
|
||||
|
||||
interface PermissionActions {
|
||||
addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => void;
|
||||
addPermission: (permission: PermissionRequest) => void;
|
||||
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
|
||||
dismissPermission: (sessionId: string, requestId: string) => void;
|
||||
isSessionAutoAccepting: (sessionId: string) => boolean;
|
||||
setSessionAutoAccept: (sessionId: string, enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
type PermissionStore = PermissionState & PermissionActions;
|
||||
@@ -53,14 +60,111 @@ const executeWithPermissionDirectory = async <T>(sessionId: string, operation: (
|
||||
return operation();
|
||||
};
|
||||
|
||||
const resolveLineage = (sessionID: string, sessions: Session[]): string[] => {
|
||||
const map = new Map<string, Session>();
|
||||
for (const session of sessions) {
|
||||
map.set(session.id, session);
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current: string | undefined = sessionID;
|
||||
while (current && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
result.push(current);
|
||||
current = map.get(current)?.parentID;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const autoRespondsPermissionBySession = (
|
||||
autoAccept: PermissionAutoAcceptMap,
|
||||
sessions: Session[],
|
||||
sessionID: string,
|
||||
): boolean => {
|
||||
for (const id of resolveLineage(sessionID, sessions)) {
|
||||
if (id in autoAccept) {
|
||||
return autoAccept[id] === true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const shouldAutoRespond = (permission: PermissionRequest, autoAccept: PermissionAutoAcceptMap): boolean => {
|
||||
if (!permission?.sessionID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const sessions = sessionStore.sessions;
|
||||
return autoRespondsPermissionBySession(
|
||||
autoAccept,
|
||||
sessions,
|
||||
permission.sessionID,
|
||||
);
|
||||
};
|
||||
|
||||
const collectPermissionDirectories = (fallbackDirectory?: string | null): string[] => {
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const dirs = new Set<string>();
|
||||
const fallback = normalizeDirectory(fallbackDirectory);
|
||||
if (fallback) {
|
||||
dirs.add(fallback);
|
||||
}
|
||||
|
||||
const currentDirectory = normalizeDirectory(opencodeClient.getDirectory());
|
||||
if (currentDirectory) {
|
||||
dirs.add(currentDirectory);
|
||||
}
|
||||
|
||||
for (const session of sessionStore.sessions) {
|
||||
const normalized = normalizeDirectory((session as { directory?: string | null }).directory);
|
||||
if (normalized) {
|
||||
dirs.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(dirs);
|
||||
};
|
||||
|
||||
const reconcilePendingAutoAccept = async (
|
||||
autoAccept: PermissionAutoAcceptMap,
|
||||
fallbackDirectory?: string | null,
|
||||
) => {
|
||||
const directories = collectPermissionDirectories(fallbackDirectory);
|
||||
if (directories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = await opencodeClient.listPendingPermissions({ directories });
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const request of pending) {
|
||||
if (!request?.sessionID || !request?.id) {
|
||||
continue;
|
||||
}
|
||||
if (!shouldAutoRespond(request, autoAccept)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await executeWithPermissionDirectory(request.sessionID, () => opencodeClient.replyToPermission(request.id, 'once'));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const usePermissionStore = create<PermissionStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
|
||||
permissions: new Map(),
|
||||
autoAccept: {},
|
||||
|
||||
addPermission: (permission: PermissionRequest, contextData?: { currentAgentContext?: Map<string, string>, sessionAgentSelections?: Map<string, string>, getSessionAgentEditMode?: (sessionId: string, agentName: string | undefined) => string }) => {
|
||||
addPermission: (permission: PermissionRequest) => {
|
||||
const sessionId = permission.sessionID;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
@@ -71,28 +175,7 @@ export const usePermissionStore = create<PermissionStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
const permissionType = permission.permission?.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 === 'allow' || effectiveMode === 'full')
|
||||
&& isEditPermissionType(permissionType);
|
||||
|
||||
if (shouldAutoApprove) {
|
||||
if (shouldAutoRespond(permission, get().autoAccept)) {
|
||||
get().respondToPermission(sessionId, permission.id, 'once').catch(() => {
|
||||
|
||||
});
|
||||
@@ -134,21 +217,58 @@ export const usePermissionStore = create<PermissionStore>()(
|
||||
return { permissions: newPermissions };
|
||||
});
|
||||
},
|
||||
|
||||
isSessionAutoAccepting: (sessionId: string) => {
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessions = useSessionStore.getState().sessions;
|
||||
return autoRespondsPermissionBySession(get().autoAccept, sessions, sessionId);
|
||||
},
|
||||
|
||||
setSessionAutoAccept: async (sessionId: string, enabled: boolean) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
autoAccept: {
|
||||
...state.autoAccept,
|
||||
[sessionId]: enabled,
|
||||
},
|
||||
}));
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const sessionDirectory = useSessionStore.getState().getDirectoryForSession(sessionId);
|
||||
void reconcilePendingAutoAccept(get().autoAccept, sessionDirectory);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "permission-store",
|
||||
storage: createJSONStorage(() => getSafeStorage()),
|
||||
partialize: (state) => ({
|
||||
permissions: Array.from(state.permissions.entries()),
|
||||
autoAccept: state.autoAccept,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
if (!isRecord(persistedState)) {
|
||||
return currentState;
|
||||
}
|
||||
const entries = sanitizePermissionEntries(persistedState.permissions);
|
||||
const autoAccept = isRecord(persistedState.autoAccept)
|
||||
? Object.fromEntries(
|
||||
Object.entries(persistedState.autoAccept).filter((entry): entry is [string, boolean] => {
|
||||
return typeof entry[0] === "string" && typeof entry[1] === "boolean";
|
||||
}),
|
||||
)
|
||||
: {};
|
||||
return {
|
||||
...currentState,
|
||||
permissions: new Map(entries),
|
||||
autoAccept,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ const readSessionSelectionMap = (): SessionSelectionMap => {
|
||||
|
||||
let sessionSelectionCache: SessionSelectionMap | null = null;
|
||||
let loadSessionsRequestSeq = 0;
|
||||
let loadSessionsInFlight: Promise<void> | null = null;
|
||||
let loadSessionsQueued = false;
|
||||
let persistSelectionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingSelectionMap: SessionSelectionMap | null = null;
|
||||
|
||||
type ProjectSessionResult = {
|
||||
projectId: string;
|
||||
@@ -152,11 +156,28 @@ const getSessionSelectionMap = (): SessionSelectionMap => {
|
||||
|
||||
const persistSessionSelectionMap = (map: SessionSelectionMap) => {
|
||||
sessionSelectionCache = map;
|
||||
try {
|
||||
safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(map));
|
||||
} catch { /* ignored */ }
|
||||
pendingSelectionMap = map;
|
||||
clearTimeout(persistSelectionTimer);
|
||||
persistSelectionTimer = setTimeout(() => {
|
||||
try {
|
||||
safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(map));
|
||||
pendingSelectionMap = null;
|
||||
} catch { /* ignored */ }
|
||||
}, 300);
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (pendingSelectionMap !== null) {
|
||||
clearTimeout(persistSelectionTimer);
|
||||
try {
|
||||
safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(pendingSelectionMap));
|
||||
} catch { /* ignored */ }
|
||||
pendingSelectionMap = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const getStoredSessionForDirectory = (directory: string | null | undefined): string | null => {
|
||||
if (!directory) {
|
||||
return null;
|
||||
@@ -463,10 +484,16 @@ export const useSessionStore = create<SessionStore>()(
|
||||
availableWorktreesByProject: new Map(),
|
||||
|
||||
loadSessions: async () => {
|
||||
const requestSeq = ++loadSessionsRequestSeq;
|
||||
const isLatestRequest = () => requestSeq === loadSessionsRequestSeq;
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
if (loadSessionsInFlight) {
|
||||
loadSessionsQueued = true;
|
||||
return loadSessionsInFlight;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const requestSeq = ++loadSessionsRequestSeq;
|
||||
const isLatestRequest = () => requestSeq === loadSessionsRequestSeq;
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const directoryStore = useDirectoryStore.getState();
|
||||
const projectsStore = useProjectsStore.getState();
|
||||
const apiClient = opencodeClient.getApiClient();
|
||||
@@ -809,14 +836,28 @@ export const useSessionStore = create<SessionStore>()(
|
||||
const fallbackSessions = dedupeSessionsById(Array.isArray(fallbackResponse.data) ? fallbackResponse.data : []);
|
||||
const fallbackProjectResults = await buildProjectResults(fallbackSessions);
|
||||
await applyProjectResults(fallbackProjectResults, []);
|
||||
} catch (error) {
|
||||
if (!isLatestRequest()) {
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isLatestRequest()) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to load sessions",
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
loadSessionsInFlight = task;
|
||||
try {
|
||||
await task;
|
||||
} finally {
|
||||
if (loadSessionsInFlight === task) {
|
||||
loadSessionsInFlight = null;
|
||||
}
|
||||
if (loadSessionsQueued) {
|
||||
loadSessionsQueued = false;
|
||||
void get().loadSessions();
|
||||
}
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to load sessions",
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1464,9 +1505,9 @@ export const useSessionStore = create<SessionStore>()(
|
||||
mergedSession.directory !== existingSession.directory ||
|
||||
mergedSession.version !== existingSession.version ||
|
||||
mergedSession.projectID !== existingSession.projectID ||
|
||||
JSON.stringify(mergedSession.time) !== JSON.stringify(existingSession.time) ||
|
||||
JSON.stringify(mergedSession.summary ?? null) !== JSON.stringify(existingSession.summary ?? null) ||
|
||||
JSON.stringify(mergedSession.share ?? null) !== JSON.stringify(existingSession.share ?? null);
|
||||
(mergedTime !== existingSession.time && JSON.stringify(mergedTime) !== JSON.stringify(existingSession.time)) ||
|
||||
(mergedSummary !== existingSession.summary && JSON.stringify(mergedSummary ?? null) !== JSON.stringify(existingSession.summary ?? null)) ||
|
||||
(mergedShare !== existingSession.share && JSON.stringify(mergedShare ?? null) !== JSON.stringify(existingSession.share ?? null));
|
||||
|
||||
const sessions = [...state.sessions];
|
||||
sessions[index] = hasChanged ? mergedSession : existingSession;
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface SyntheticContextPart {
|
||||
|
||||
export type NewSessionDraftState = {
|
||||
open: boolean;
|
||||
selectedProjectId?: string | null;
|
||||
directoryOverride: string | null;
|
||||
parentID: string | null;
|
||||
title?: string;
|
||||
@@ -215,7 +216,8 @@ export interface SessionStore {
|
||||
setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void;
|
||||
loadSessions: () => Promise<void>;
|
||||
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[]; targetFolderId?: string }) => void;
|
||||
openNewSessionDraft: (options?: { projectId?: string | null; directoryOverride?: string | null; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[]; targetFolderId?: string }) => void;
|
||||
setNewSessionDraftTarget: (target: { projectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
closeNewSessionDraft: () => void;
|
||||
|
||||
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
|
||||
|
||||
@@ -60,6 +60,31 @@ const getConfigDirectory = (): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const AGENTS_LOAD_CACHE_TTL_MS = 5000;
|
||||
const DEFAULT_AGENTS_CACHE_KEY = '__default__';
|
||||
const agentsLastLoadedAt = new Map<string, number>();
|
||||
const agentsLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
const getAgentsCacheKey = (directory: string | null): string => {
|
||||
return directory?.trim() || DEFAULT_AGENTS_CACHE_KEY;
|
||||
};
|
||||
|
||||
const buildAgentsSignature = (agents: Agent[]): string => {
|
||||
return agents
|
||||
.map((agent) => {
|
||||
const extended = agent as AgentWithExtras;
|
||||
return [
|
||||
agent.name,
|
||||
extended.scope ?? '',
|
||||
extended.group ?? '',
|
||||
extended.description ?? '',
|
||||
String(extended.hidden === true),
|
||||
String(extended.native === true),
|
||||
].join('|');
|
||||
})
|
||||
.join('||');
|
||||
};
|
||||
|
||||
export type AgentScope = 'user' | 'project';
|
||||
|
||||
export interface AgentConfig {
|
||||
@@ -183,74 +208,100 @@ export const useAgentsStore = create<AgentsStore>()(
|
||||
},
|
||||
|
||||
loadAgents: async () => {
|
||||
set({ isLoading: true });
|
||||
const previousAgents = get().agents;
|
||||
const configDirectory = getConfigDirectory();
|
||||
const cacheKey = getAgentsCacheKey(configDirectory);
|
||||
const now = Date.now();
|
||||
const loadedAt = agentsLastLoadedAt.get(cacheKey) ?? 0;
|
||||
const hasCachedAgents = get().agents.length > 0;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
|
||||
// Ensure we list agents using the correct project context
|
||||
const agents = await opencodeClient.withDirectory(configDirectory, () => opencodeClient.listAgents());
|
||||
|
||||
const agentsWithScope = await Promise.all(
|
||||
agents.map(async (agent) => {
|
||||
try {
|
||||
// Force no-cache to ensure we get the latest scope info
|
||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Prioritize explicit scope from server response
|
||||
let scope = data.scope;
|
||||
|
||||
// Fallback to deducing from sources if top-level scope is missing
|
||||
if (!scope && data.sources) {
|
||||
const sources = data.sources;
|
||||
scope = (sources.md?.exists ? sources.md.scope : undefined)
|
||||
?? (sources.json?.exists ? sources.json.scope : undefined)
|
||||
?? sources.md?.scope
|
||||
?? sources.json?.scope;
|
||||
}
|
||||
|
||||
// Parse subfolder group from file path
|
||||
const mdPath: string | null | undefined = data.sources?.md?.path;
|
||||
const group = parseAgentGroup(mdPath);
|
||||
|
||||
if (scope === 'project' || scope === 'user') {
|
||||
return { ...agent, scope: scope as AgentScope, group };
|
||||
}
|
||||
|
||||
// Explicitly set null scope if not found, to clear stale state
|
||||
return { ...agent, scope: undefined, group };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[AgentsStore] Failed to fetch config for agent ${agent.name}:`, err);
|
||||
}
|
||||
return agent;
|
||||
})
|
||||
);
|
||||
|
||||
if (JSON.stringify(previousAgents) !== JSON.stringify(agentsWithScope)) {
|
||||
set({ agents: agentsWithScope, isLoading: false });
|
||||
} else {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// ignore error
|
||||
}
|
||||
if (hasCachedAgents && now - loadedAt < AGENTS_LOAD_CACHE_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const inFlight = agentsLoadInFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
set({ isLoading: true });
|
||||
const previousAgents = get().agents;
|
||||
const previousSignature = buildAgentsSignature(previousAgents);
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
|
||||
// Ensure we list agents using the correct project context
|
||||
const agents = await opencodeClient.withDirectory(configDirectory, () => opencodeClient.listAgents());
|
||||
|
||||
const agentsWithScope = await Promise.all(
|
||||
agents.map(async (agent) => {
|
||||
try {
|
||||
// Force no-cache to ensure we get the latest scope info
|
||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Prioritize explicit scope from server response
|
||||
let scope = data.scope;
|
||||
|
||||
// Fallback to deducing from sources if top-level scope is missing
|
||||
if (!scope && data.sources) {
|
||||
const sources = data.sources;
|
||||
scope = (sources.md?.exists ? sources.md.scope : undefined)
|
||||
?? (sources.json?.exists ? sources.json.scope : undefined)
|
||||
?? sources.md?.scope
|
||||
?? sources.json?.scope;
|
||||
}
|
||||
|
||||
// Parse subfolder group from file path
|
||||
const mdPath: string | null | undefined = data.sources?.md?.path;
|
||||
const group = parseAgentGroup(mdPath);
|
||||
|
||||
if (scope === 'project' || scope === 'user') {
|
||||
return { ...agent, scope: scope as AgentScope, group };
|
||||
}
|
||||
|
||||
// Explicitly set null scope if not found, to clear stale state
|
||||
return { ...agent, scope: undefined, group };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[AgentsStore] Failed to fetch config for agent ${agent.name}:`, err);
|
||||
}
|
||||
return agent;
|
||||
})
|
||||
);
|
||||
|
||||
const nextSignature = buildAgentsSignature(agentsWithScope);
|
||||
if (previousSignature !== nextSignature) {
|
||||
set({ agents: agentsWithScope, isLoading: false });
|
||||
} else {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
agentsLastLoadedAt.set(cacheKey, Date.now());
|
||||
return true;
|
||||
} catch {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
|
||||
set({ isLoading: false });
|
||||
return false;
|
||||
})();
|
||||
|
||||
agentsLoadInFlight.set(cacheKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
agentsLoadInFlight.delete(cacheKey);
|
||||
}
|
||||
|
||||
set({ isLoading: false });
|
||||
return false;
|
||||
},
|
||||
|
||||
createAgent: async (config: AgentConfig) => {
|
||||
|
||||
@@ -36,6 +36,27 @@ export const isCommandBuiltIn = (command: Command): boolean => {
|
||||
|
||||
const CONFIG_EVENT_SOURCE = "useCommandsStore";
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const COMMANDS_LOAD_CACHE_TTL_MS = 5000;
|
||||
const DEFAULT_COMMANDS_CACHE_KEY = '__default__';
|
||||
const commandsLastLoadedAt = new Map<string, number>();
|
||||
const commandsLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
const getCommandsCacheKey = (directory: string | null): string => {
|
||||
return directory?.trim() || DEFAULT_COMMANDS_CACHE_KEY;
|
||||
};
|
||||
|
||||
const buildCommandsSignature = (commands: Command[]): string => {
|
||||
return commands
|
||||
.map((command) => [
|
||||
command.name,
|
||||
command.scope ?? '',
|
||||
command.description ?? '',
|
||||
command.agent ?? '',
|
||||
command.model ?? '',
|
||||
String(command.isBuiltIn === true),
|
||||
].join('|'))
|
||||
.join('||');
|
||||
};
|
||||
|
||||
const getRequestDirectory = (): string | null => {
|
||||
try {
|
||||
@@ -116,77 +137,103 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
},
|
||||
|
||||
loadCommands: async () => {
|
||||
set({ isLoading: true });
|
||||
const previousCommands = get().commands;
|
||||
let lastError: unknown = null;
|
||||
const directory = getRequestDirectory();
|
||||
const cacheKey = getCommandsCacheKey(directory);
|
||||
const now = Date.now();
|
||||
const loadedAt = commandsLastLoadedAt.get(cacheKey) ?? 0;
|
||||
const hasCachedCommands = get().commands.length > 0;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
// Ensure the list is scoped to the same directory we use for config source detection.
|
||||
const commands = await opencodeClient.withDirectory(
|
||||
directory,
|
||||
() => opencodeClient.listCommandsWithDetails()
|
||||
);
|
||||
|
||||
const commandsWithScope = await Promise.all(
|
||||
commands.map(async (cmd) => {
|
||||
try {
|
||||
// Force no-cache
|
||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
...(directory ? { 'x-opencode-directory': directory } : {}),
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Prioritize explicit scope
|
||||
let scope = data.scope;
|
||||
|
||||
// Fallback to deducing from sources
|
||||
if (!scope && data.sources) {
|
||||
const sources = data.sources;
|
||||
scope = (sources.md?.exists ? sources.md.scope : undefined)
|
||||
?? (sources.json?.exists ? sources.json.scope : undefined)
|
||||
?? sources.md?.scope
|
||||
?? sources.json?.scope;
|
||||
}
|
||||
|
||||
if (scope === 'project' || scope === 'user') {
|
||||
return { ...cmd, scope: scope as CommandScope };
|
||||
}
|
||||
|
||||
// Explicitly set null scope if not found
|
||||
return { ...cmd, scope: undefined };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[CommandsStore] Failed to fetch config for command ${cmd.name}:`, err);
|
||||
}
|
||||
return cmd;
|
||||
})
|
||||
);
|
||||
|
||||
if (JSON.stringify(previousCommands) !== JSON.stringify(commandsWithScope)) {
|
||||
set({ commands: commandsWithScope, isLoading: false });
|
||||
} else {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const waitMs = 200 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
if (hasCachedCommands && now - loadedAt < COMMANDS_LOAD_CACHE_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error("Failed to load commands:", lastError);
|
||||
set({ commands: previousCommands, isLoading: false });
|
||||
return false;
|
||||
const inFlight = commandsLoadInFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
set({ isLoading: true });
|
||||
const previousCommands = get().commands;
|
||||
const previousSignature = buildCommandsSignature(previousCommands);
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
// Ensure the list is scoped to the same directory we use for config source detection.
|
||||
const commands = await opencodeClient.withDirectory(
|
||||
directory,
|
||||
() => opencodeClient.listCommandsWithDetails()
|
||||
);
|
||||
|
||||
const commandsWithScope = await Promise.all(
|
||||
commands.map(async (cmd) => {
|
||||
try {
|
||||
// Force no-cache
|
||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
...(directory ? { 'x-opencode-directory': directory } : {}),
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Prioritize explicit scope
|
||||
let scope = data.scope;
|
||||
|
||||
// Fallback to deducing from sources
|
||||
if (!scope && data.sources) {
|
||||
const sources = data.sources;
|
||||
scope = (sources.md?.exists ? sources.md.scope : undefined)
|
||||
?? (sources.json?.exists ? sources.json.scope : undefined)
|
||||
?? sources.md?.scope
|
||||
?? sources.json?.scope;
|
||||
}
|
||||
|
||||
if (scope === 'project' || scope === 'user') {
|
||||
return { ...cmd, scope: scope as CommandScope };
|
||||
}
|
||||
|
||||
// Explicitly set null scope if not found
|
||||
return { ...cmd, scope: undefined };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[CommandsStore] Failed to fetch config for command ${cmd.name}:`, err);
|
||||
}
|
||||
return cmd;
|
||||
})
|
||||
);
|
||||
|
||||
const nextSignature = buildCommandsSignature(commandsWithScope);
|
||||
if (previousSignature !== nextSignature) {
|
||||
set({ commands: commandsWithScope, isLoading: false });
|
||||
} else {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
commandsLastLoadedAt.set(cacheKey, Date.now());
|
||||
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;
|
||||
})();
|
||||
|
||||
commandsLoadInFlight.set(cacheKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
commandsLoadInFlight.delete(cacheKey);
|
||||
}
|
||||
},
|
||||
|
||||
createCommand: async (config: CommandConfig) => {
|
||||
|
||||
@@ -7,10 +7,13 @@ import type {
|
||||
GitIdentitySummary,
|
||||
} from '@/lib/api/types';
|
||||
|
||||
const GIT_POLL_BASE_INTERVAL = 5000;
|
||||
const GIT_POLL_MAX_INTERVAL = 10000;
|
||||
const GIT_POLL_BASE_INTERVAL = 10000;
|
||||
const GIT_POLL_MAX_INTERVAL = 30000;
|
||||
const GIT_POLL_BUSY_BASE_INTERVAL = 15000;
|
||||
const GIT_POLL_BUSY_MAX_INTERVAL = 40000;
|
||||
const GIT_POLL_BACKOFF_STEP = 5000;
|
||||
const LOG_STALE_THRESHOLD = 10000;
|
||||
const REPO_CHECK_STALE_THRESHOLD = 60_000;
|
||||
const DIFF_PREFETCH_MAX_FILES = 25;
|
||||
const DIFF_PREFETCH_FOCUS_MAX_FILES = 40;
|
||||
const DIFF_PREFETCH_CONCURRENCY = 4;
|
||||
@@ -28,6 +31,7 @@ interface DirectoryGitState {
|
||||
log: GitLogResponse | null;
|
||||
identity: GitIdentitySummary | null;
|
||||
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>;
|
||||
lastRepoCheckAt: number;
|
||||
lastStatusFetch: number;
|
||||
lastStatusChange: number;
|
||||
lastLogFetch: number;
|
||||
@@ -48,6 +52,7 @@ interface GitStore {
|
||||
|
||||
pollIntervalId: ReturnType<typeof setTimeout> | null;
|
||||
currentPollInterval: number;
|
||||
pollingMode: 'normal' | 'busy';
|
||||
|
||||
setActiveDirectory: (directory: string | null) => void;
|
||||
getDirectoryState: (directory: string) => DirectoryGitState | null;
|
||||
@@ -67,6 +72,7 @@ interface GitStore {
|
||||
setLogMaxCount: (directory: string, maxCount: number) => void;
|
||||
|
||||
startPolling: (git: GitAPI) => void;
|
||||
setPollingMode: (mode: 'normal' | 'busy') => void;
|
||||
stopPolling: () => void;
|
||||
|
||||
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
||||
@@ -90,6 +96,7 @@ interface GitAPI {
|
||||
|
||||
const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
|
||||
const diffFetchGenerationByDirectory = new Map<string, number>();
|
||||
const inFlightStatusFetchesByDirectory = new Map<string, Promise<boolean>>();
|
||||
|
||||
const getDiffFetchGeneration = (directory: string): number =>
|
||||
diffFetchGenerationByDirectory.get(directory) ?? 0;
|
||||
@@ -117,6 +124,7 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
|
||||
log: null,
|
||||
identity: null,
|
||||
diffCache: new Map(),
|
||||
lastRepoCheckAt: 0,
|
||||
lastStatusFetch: 0,
|
||||
lastStatusChange: 0,
|
||||
lastLogFetch: 0,
|
||||
@@ -262,6 +270,20 @@ const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus |
|
||||
return changed;
|
||||
};
|
||||
|
||||
const getPollingBounds = (mode: 'normal' | 'busy') => {
|
||||
if (mode === 'busy') {
|
||||
return {
|
||||
base: GIT_POLL_BUSY_BASE_INTERVAL,
|
||||
max: GIT_POLL_BUSY_MAX_INTERVAL,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
base: GIT_POLL_BASE_INTERVAL,
|
||||
max: GIT_POLL_MAX_INTERVAL,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGitStore = create<GitStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
@@ -274,6 +296,7 @@ export const useGitStore = create<GitStore>()(
|
||||
isLoadingIdentity: false,
|
||||
pollIntervalId: null,
|
||||
currentPollInterval: GIT_POLL_BASE_INTERVAL,
|
||||
pollingMode: 'normal',
|
||||
|
||||
setActiveDirectory: (directory) => {
|
||||
const { activeDirectory, directories, recentDirectories } = get();
|
||||
@@ -304,96 +327,124 @@ export const useGitStore = create<GitStore>()(
|
||||
},
|
||||
|
||||
fetchStatus: async (directory, git, options = {}) => {
|
||||
const { silent = false } = options;
|
||||
const { directories } = get();
|
||||
let dirState = directories.get(directory);
|
||||
|
||||
if (!dirState) {
|
||||
dirState = createEmptyDirectoryState();
|
||||
const existing = inFlightStatusFetchesByDirectory.get(directory);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
set({ isLoadingStatus: true });
|
||||
}
|
||||
const fetchPromise = (async () => {
|
||||
const { silent = false } = options;
|
||||
const { directories } = get();
|
||||
let dirState = directories.get(directory);
|
||||
|
||||
let statusChanged = false;
|
||||
if (!dirState) {
|
||||
dirState = createEmptyDirectoryState();
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
set({ isLoadingStatus: true });
|
||||
}
|
||||
|
||||
let statusChanged = false;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const shouldProbeRepository =
|
||||
dirState.isGitRepo !== true ||
|
||||
now - (dirState.lastRepoCheckAt || 0) > REPO_CHECK_STALE_THRESHOLD;
|
||||
|
||||
let isRepo = dirState.isGitRepo === true;
|
||||
if (shouldProbeRepository) {
|
||||
isRepo = await git.checkIsGitRepository(directory);
|
||||
}
|
||||
|
||||
if (!isRepo) {
|
||||
const newDirectories = new Map(directories);
|
||||
newDirectories.set(directory, {
|
||||
...dirState,
|
||||
isGitRepo: false,
|
||||
status: null,
|
||||
lastRepoCheckAt: now,
|
||||
lastStatusFetch: 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();
|
||||
|
||||
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
|
||||
|
||||
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
|
||||
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
|
||||
|
||||
const nextDiffCache = new Map(currentDirState.diffCache);
|
||||
|
||||
// Drop cache for removed files
|
||||
for (const oldPath of oldPaths) {
|
||||
if (!newPaths.has(oldPath)) {
|
||||
nextDiffCache.delete(oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop cache for files whose state/content changed
|
||||
for (const filePath of changedPaths) {
|
||||
nextDiffCache.delete(filePath);
|
||||
}
|
||||
|
||||
const hasFileContentChange = changedPaths.size > 0;
|
||||
if (hasFileContentChange) {
|
||||
bumpDiffFetchGeneration(directory);
|
||||
}
|
||||
|
||||
newDirectories.set(directory, {
|
||||
...currentDirState,
|
||||
isGitRepo: true,
|
||||
status: newStatus,
|
||||
diffCache: nextDiffCache,
|
||||
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
|
||||
lastStatusFetch: Date.now(),
|
||||
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
|
||||
});
|
||||
set({ directories: newDirectories });
|
||||
} else {
|
||||
|
||||
const newDirectories = new Map(get().directories);
|
||||
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||
newDirectories.set(directory, {
|
||||
...currentDirState,
|
||||
isGitRepo: true,
|
||||
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
|
||||
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;
|
||||
})();
|
||||
|
||||
inFlightStatusFetchesByDirectory.set(directory, fetchPromise);
|
||||
|
||||
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();
|
||||
|
||||
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
|
||||
|
||||
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
|
||||
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
|
||||
|
||||
const nextDiffCache = new Map(currentDirState.diffCache);
|
||||
|
||||
// Drop cache for removed files
|
||||
for (const oldPath of oldPaths) {
|
||||
if (!newPaths.has(oldPath)) {
|
||||
nextDiffCache.delete(oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop cache for files whose state/content changed
|
||||
for (const filePath of changedPaths) {
|
||||
nextDiffCache.delete(filePath);
|
||||
}
|
||||
|
||||
const hasFileContentChange = changedPaths.size > 0;
|
||||
if (hasFileContentChange) {
|
||||
bumpDiffFetchGeneration(directory);
|
||||
}
|
||||
|
||||
newDirectories.set(directory, {
|
||||
...currentDirState,
|
||||
isGitRepo: true,
|
||||
status: newStatus,
|
||||
diffCache: nextDiffCache,
|
||||
lastStatusFetch: Date.now(),
|
||||
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
|
||||
});
|
||||
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);
|
||||
return await fetchPromise;
|
||||
} finally {
|
||||
if (!silent) {
|
||||
set({ isLoadingStatus: false });
|
||||
if (inFlightStatusFetchesByDirectory.get(directory) === fetchPromise) {
|
||||
inFlightStatusFetchesByDirectory.delete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
return statusChanged;
|
||||
},
|
||||
|
||||
fetchBranches: async (directory, git) => {
|
||||
@@ -637,6 +688,21 @@ export const useGitStore = create<GitStore>()(
|
||||
set({ directories: newDirectories });
|
||||
},
|
||||
|
||||
setPollingMode: (mode) => {
|
||||
const { pollingMode, currentPollInterval } = get();
|
||||
if (pollingMode === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = getPollingBounds(mode);
|
||||
const nextInterval = Math.min(Math.max(currentPollInterval, bounds.base), bounds.max);
|
||||
|
||||
set({
|
||||
pollingMode: mode,
|
||||
currentPollInterval: nextInterval,
|
||||
});
|
||||
},
|
||||
|
||||
startPolling: (git) => {
|
||||
const { pollIntervalId } = get();
|
||||
if (pollIntervalId) return;
|
||||
@@ -677,14 +743,15 @@ export const useGitStore = create<GitStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = getPollingBounds(get().pollingMode);
|
||||
if (anyStatusChanged) {
|
||||
// Reset to base interval on changes
|
||||
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||
set({ currentPollInterval: bounds.base });
|
||||
} else {
|
||||
// Backoff when no changes
|
||||
const newInterval = Math.min(
|
||||
currentPollInterval + GIT_POLL_BACKOFF_STEP,
|
||||
GIT_POLL_MAX_INTERVAL
|
||||
bounds.max
|
||||
);
|
||||
set({ currentPollInterval: newInterval });
|
||||
}
|
||||
@@ -699,14 +766,15 @@ export const useGitStore = create<GitStore>()(
|
||||
return timeoutId;
|
||||
};
|
||||
|
||||
set({ pollIntervalId: schedulePoll(), currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||
const bounds = getPollingBounds(get().pollingMode);
|
||||
set({ pollIntervalId: schedulePoll(), currentPollInterval: bounds.base });
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
const { pollIntervalId } = get();
|
||||
if (pollIntervalId) {
|
||||
clearTimeout(pollIntervalId);
|
||||
set({ pollIntervalId: null, currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||
set({ pollIntervalId: null, currentPollInterval: GIT_POLL_BASE_INTERVAL, pollingMode: 'normal' });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -72,6 +72,14 @@ export const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Re
|
||||
};
|
||||
|
||||
const CLIENT_RELOAD_DELAY_MS = 800;
|
||||
const MCP_LOAD_CACHE_TTL_MS = 5000;
|
||||
const DEFAULT_MCP_CACHE_KEY = '__default__';
|
||||
const mcpLastLoadedAt = new Map<string, number>();
|
||||
const mcpLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
const getMcpCacheKey = (directory: string | null): string => {
|
||||
return directory?.trim() || DEFAULT_MCP_CACHE_KEY;
|
||||
};
|
||||
|
||||
// ============== STORE ==============
|
||||
|
||||
@@ -104,23 +112,47 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
setMcpDraft: (draft) => set({ mcpDraft: draft }),
|
||||
|
||||
loadMcpConfigs: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp${queryParams}`, {
|
||||
headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load MCP configs');
|
||||
}
|
||||
const data: McpServerWithScope[] = await response.json();
|
||||
set({ mcpServers: data, isLoading: false });
|
||||
const configDirectory = getConfigDirectory();
|
||||
const cacheKey = getMcpCacheKey(configDirectory);
|
||||
const now = Date.now();
|
||||
const loadedAt = mcpLastLoadedAt.get(cacheKey) ?? 0;
|
||||
const hasCachedConfigs = get().mcpServers.length > 0;
|
||||
|
||||
if (hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[McpConfigStore] Failed to load MCP configs:', error);
|
||||
set({ isLoading: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
const inFlight = mcpLoadInFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp${queryParams}`, {
|
||||
headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load MCP configs');
|
||||
}
|
||||
const data: McpServerWithScope[] = await response.json();
|
||||
set({ mcpServers: data, isLoading: false });
|
||||
mcpLastLoadedAt.set(cacheKey, Date.now());
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[McpConfigStore] Failed to load MCP configs:', error);
|
||||
set({ isLoading: false });
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
mcpLoadInFlight.set(cacheKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
mcpLoadInFlight.delete(cacheKey);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ const safeStorage = getSafeStorage();
|
||||
let diskWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let diskHydrated = false;
|
||||
let diskHydrationInFlight = false;
|
||||
let persistFoldersTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let persistCollapsedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingFoldersMap: SessionFoldersMap | null = null;
|
||||
let pendingCollapsedIds: Set<string> | null = null;
|
||||
|
||||
const isVSCodeWebview = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -185,21 +189,50 @@ const readPersistedCollapsed = (): Set<string> => {
|
||||
};
|
||||
|
||||
const persistFolders = (foldersMap: SessionFoldersMap): void => {
|
||||
try {
|
||||
safeStorage.setItem(FOLDERS_STORAGE_KEY, JSON.stringify(foldersMap));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
pendingFoldersMap = foldersMap;
|
||||
clearTimeout(persistFoldersTimer);
|
||||
persistFoldersTimer = setTimeout(() => {
|
||||
try {
|
||||
safeStorage.setItem(FOLDERS_STORAGE_KEY, JSON.stringify(foldersMap));
|
||||
pendingFoldersMap = null;
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const persistCollapsed = (collapsedFolderIds: Set<string>): void => {
|
||||
try {
|
||||
safeStorage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(Array.from(collapsedFolderIds)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
pendingCollapsedIds = collapsedFolderIds;
|
||||
clearTimeout(persistCollapsedTimer);
|
||||
persistCollapsedTimer = setTimeout(() => {
|
||||
try {
|
||||
safeStorage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(Array.from(collapsedFolderIds)));
|
||||
pendingCollapsedIds = null;
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (pendingFoldersMap !== null) {
|
||||
clearTimeout(persistFoldersTimer);
|
||||
try {
|
||||
safeStorage.setItem(FOLDERS_STORAGE_KEY, JSON.stringify(pendingFoldersMap));
|
||||
} catch { /* ignored */ }
|
||||
pendingFoldersMap = null;
|
||||
}
|
||||
if (pendingCollapsedIds !== null) {
|
||||
clearTimeout(persistCollapsedTimer);
|
||||
try {
|
||||
safeStorage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(Array.from(pendingCollapsedIds)));
|
||||
} catch { /* ignored */ }
|
||||
pendingCollapsedIds = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const persistState = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set<string>): void => {
|
||||
persistFolders(foldersMap);
|
||||
persistCollapsed(collapsedFolderIds);
|
||||
|
||||
@@ -17,10 +17,12 @@ import { useDirectoryStore } from "./useDirectoryStore";
|
||||
import { useConfigStore } from "./useConfigStore";
|
||||
import { useProjectsStore } from "./useProjectsStore";
|
||||
import { useSessionFoldersStore } from "./useSessionFoldersStore";
|
||||
import { getSafeStorage } from "./utils/safeStorage";
|
||||
import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta";
|
||||
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation";
|
||||
import { flattenAssistantTextParts } from "@/lib/messages/messageText";
|
||||
import { normalizeMessageRecordsForProjection } from "./utils/messageProjectors";
|
||||
import type { ProjectEntry } from "@/lib/api/types";
|
||||
|
||||
export type { AttachedFile, EditPermissionMode };
|
||||
export { MEMORY_LIMITS, ACTIVE_SESSION_WINDOW } from "./types/sessionTypes";
|
||||
@@ -47,6 +49,62 @@ const normalizePath = (value?: string | null): string | null => {
|
||||
};
|
||||
|
||||
const sessionChoiceAnalysisSignature = new Map<string, string>();
|
||||
const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget";
|
||||
|
||||
type PersistedDraftTarget = {
|
||||
projectId: string | null;
|
||||
directory: string | null;
|
||||
};
|
||||
|
||||
const safeStorage = getSafeStorage();
|
||||
|
||||
const readPersistedDraftTarget = (): PersistedDraftTarget | null => {
|
||||
try {
|
||||
const raw = safeStorage.getItem(DRAFT_TARGET_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { projectId?: unknown; directory?: unknown };
|
||||
return {
|
||||
projectId: typeof parsed?.projectId === "string" ? parsed.projectId : null,
|
||||
directory: normalizePath(typeof parsed?.directory === "string" ? parsed.directory : null),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const persistDraftTarget = (target: PersistedDraftTarget): void => {
|
||||
try {
|
||||
safeStorage.setItem(DRAFT_TARGET_STORAGE_KEY, JSON.stringify(target));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
};
|
||||
|
||||
const resolveProjectForDirectory = (projects: ProjectEntry[], directory: string | null): ProjectEntry | null => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!normalizedDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestMatch: ProjectEntry | null = null;
|
||||
for (const project of projects) {
|
||||
const projectPath = normalizePath(project.path);
|
||||
if (!projectPath) {
|
||||
continue;
|
||||
}
|
||||
const isExact = normalizedDirectory === projectPath;
|
||||
const isNested = normalizedDirectory.startsWith(`${projectPath}/`);
|
||||
if (!isExact && !isNested) {
|
||||
continue;
|
||||
}
|
||||
if (!bestMatch || projectPath.length > (normalizePath(bestMatch.path)?.length ?? 0)) {
|
||||
bestMatch = project;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
};
|
||||
|
||||
const buildSessionChoiceAnalysisSignature = (messages: Array<{ info: Message; parts: Part[] }>): string => {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
@@ -120,7 +178,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
pendingInputText: null,
|
||||
pendingInputMode: 'replace',
|
||||
pendingSyntheticParts: null,
|
||||
newSessionDraft: { open: true, directoryOverride: null, parentID: null },
|
||||
newSessionDraft: { open: true, selectedProjectId: null, directoryOverride: null, parentID: null },
|
||||
|
||||
// Voice state (initialized to disconnected/idle)
|
||||
voiceStatus: 'disconnected',
|
||||
@@ -149,18 +207,71 @@ export const useSessionStore = create<SessionStore>()(
|
||||
loadSessions: () => useSessionManagementStore.getState().loadSessions(),
|
||||
|
||||
openNewSessionDraft: (options) => {
|
||||
// Use explicit directoryOverride if provided, otherwise use active project path
|
||||
let directory: string | null = null;
|
||||
if (options?.directoryOverride !== undefined) {
|
||||
directory = options.directoryOverride;
|
||||
} else {
|
||||
const activeProject = useProjectsStore.getState().getActiveProject();
|
||||
directory = activeProject?.path ?? null;
|
||||
}
|
||||
const projectsState = useProjectsStore.getState();
|
||||
const projects = projectsState.projects;
|
||||
const activeProject = projectsState.getActiveProject();
|
||||
const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null);
|
||||
const persistedTarget = readPersistedDraftTarget();
|
||||
|
||||
const explicitDirectory = options?.directoryOverride !== undefined
|
||||
? normalizePath(options.directoryOverride)
|
||||
: null;
|
||||
const explicitProject = options?.projectId
|
||||
? projects.find((project) => project.id === options.projectId) ?? null
|
||||
: null;
|
||||
|
||||
const inferredProjectFromDirectory = resolveProjectForDirectory(projects, explicitDirectory);
|
||||
const fallbackProject = (() => {
|
||||
if (activeProject) {
|
||||
return activeProject;
|
||||
}
|
||||
if (projectsState.activeProjectId) {
|
||||
return projects.find((project) => project.id === projectsState.activeProjectId) ?? null;
|
||||
}
|
||||
return projects[0] ?? null;
|
||||
})();
|
||||
|
||||
const persistedProjectById = persistedTarget?.projectId
|
||||
? projects.find((project) => project.id === persistedTarget.projectId) ?? null
|
||||
: null;
|
||||
const persistedProjectByDirectory = resolveProjectForDirectory(projects, persistedTarget?.directory ?? null);
|
||||
const currentDirectoryProject = resolveProjectForDirectory(projects, currentDirectory);
|
||||
|
||||
const selectedProject = (() => {
|
||||
if (explicitProject || explicitDirectory !== null) {
|
||||
return explicitProject ?? inferredProjectFromDirectory ?? fallbackProject;
|
||||
}
|
||||
if (currentDirectory) {
|
||||
return currentDirectoryProject ?? fallbackProject;
|
||||
}
|
||||
return persistedProjectByDirectory ?? persistedProjectById ?? fallbackProject;
|
||||
})();
|
||||
|
||||
const directory = (() => {
|
||||
if (explicitDirectory !== null) {
|
||||
return explicitDirectory;
|
||||
}
|
||||
if (explicitProject) {
|
||||
return normalizePath(explicitProject.path ?? null);
|
||||
}
|
||||
if (currentDirectory) {
|
||||
return currentDirectory;
|
||||
}
|
||||
if (persistedTarget?.directory) {
|
||||
return persistedTarget.directory;
|
||||
}
|
||||
return normalizePath(selectedProject?.path ?? null);
|
||||
})();
|
||||
|
||||
persistDraftTarget({
|
||||
projectId: selectedProject?.id ?? null,
|
||||
directory,
|
||||
});
|
||||
|
||||
set({
|
||||
newSessionDraft: {
|
||||
open: true,
|
||||
selectedProjectId: selectedProject?.id ?? null,
|
||||
directoryOverride: directory,
|
||||
parentID: options?.parentID ?? null,
|
||||
title: options?.title,
|
||||
@@ -200,10 +311,39 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
setNewSessionDraftTarget: ({ projectId, directoryOverride }) => {
|
||||
const projects = useProjectsStore.getState().projects;
|
||||
const project = projectId
|
||||
? projects.find((entry) => entry.id === projectId) ?? null
|
||||
: null;
|
||||
const normalizedDirectory = normalizePath(directoryOverride);
|
||||
const normalizedProjectPath = normalizePath(project?.path ?? null);
|
||||
const nextDirectory = normalizedDirectory ?? normalizedProjectPath ?? null;
|
||||
|
||||
set((state) => {
|
||||
if (!state.newSessionDraft?.open) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
newSessionDraft: {
|
||||
...state.newSessionDraft,
|
||||
selectedProjectId: project?.id ?? null,
|
||||
directoryOverride: nextDirectory,
|
||||
parentID: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
persistDraftTarget({
|
||||
projectId: project?.id ?? null,
|
||||
directory: nextDirectory,
|
||||
});
|
||||
},
|
||||
|
||||
closeNewSessionDraft: () => {
|
||||
const realCurrentSessionId = useSessionManagementStore.getState().currentSessionId;
|
||||
set({
|
||||
newSessionDraft: { open: false, directoryOverride: null, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined, targetFolderId: undefined },
|
||||
newSessionDraft: { open: false, selectedProjectId: null, directoryOverride: null, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined, targetFolderId: undefined },
|
||||
currentSessionId: realCurrentSessionId,
|
||||
});
|
||||
},
|
||||
@@ -375,6 +515,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
if (draft?.open) {
|
||||
const draftTargetFolderId = draft.targetFolderId;
|
||||
const draftDirectoryOverride = draft.directoryOverride ?? null;
|
||||
const draftProjectId = draft.selectedProjectId ?? null;
|
||||
|
||||
const created = await useSessionManagementStore
|
||||
.getState()
|
||||
@@ -384,6 +525,11 @@ export const useSessionStore = create<SessionStore>()(
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
persistDraftTarget({
|
||||
projectId: draftProjectId,
|
||||
directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null),
|
||||
});
|
||||
|
||||
const configState = useConfigStore.getState();
|
||||
const draftAgentName = configState.currentAgentName;
|
||||
const effectiveDraftAgent = trimmedAgent ?? draftAgentName;
|
||||
@@ -560,12 +706,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record<string, unknown>) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo),
|
||||
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null),
|
||||
addPermission: (permission: PermissionRequest) => {
|
||||
const contextData = {
|
||||
currentAgentContext: useContextStore.getState().currentAgentContext,
|
||||
sessionAgentSelections: useContextStore.getState().sessionAgentSelections,
|
||||
getSessionAgentEditMode: useContextStore.getState().getSessionAgentEditMode,
|
||||
};
|
||||
return usePermissionStore.getState().addPermission(permission, contextData);
|
||||
return usePermissionStore.getState().addPermission(permission);
|
||||
},
|
||||
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, requestId, response),
|
||||
dismissPermission: (sessionId: string, requestId: string) => usePermissionStore.getState().dismissPermission(sessionId, requestId),
|
||||
@@ -1067,13 +1208,22 @@ useDirectoryStore.subscribe((state, prevState) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = useProjectsStore.getState().projects;
|
||||
const resolvedProject = resolveProjectForDirectory(projects, nextDirectory);
|
||||
|
||||
useSessionStore.setState((store) => ({
|
||||
newSessionDraft: {
|
||||
...store.newSessionDraft,
|
||||
selectedProjectId: resolvedProject?.id ?? store.newSessionDraft.selectedProjectId ?? null,
|
||||
directoryOverride: nextDirectory,
|
||||
parentID: null,
|
||||
},
|
||||
}));
|
||||
|
||||
persistDraftTarget({
|
||||
projectId: resolvedProject?.id ?? draft.selectedProjectId ?? null,
|
||||
directory: nextDirectory,
|
||||
});
|
||||
});
|
||||
|
||||
const bootDraftOpen = useSessionStore.getState().newSessionDraft?.open;
|
||||
|
||||
@@ -35,6 +35,15 @@ const FALLBACK_SOURCES: SkillsCatalogSource[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const SKILLS_CATALOG_LOAD_CACHE_TTL_MS = 5000;
|
||||
const DEFAULT_SKILLS_CATALOG_CACHE_KEY = '__default__';
|
||||
const skillsCatalogLastLoadedAt = new Map<string, number>();
|
||||
const skillsCatalogLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
const getSkillsCatalogCacheKey = (directory: string | null): string => {
|
||||
return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY;
|
||||
};
|
||||
|
||||
const getCurrentDirectory = (): string | null => {
|
||||
const opencodeDirectory = opencodeClient.getDirectory();
|
||||
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
|
||||
@@ -108,75 +117,99 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
setSelectedSource: (id) => set({ selectedSourceId: id }),
|
||||
|
||||
loadCatalog: async (options) => {
|
||||
set({ isLoadingCatalog: true, lastCatalogError: null });
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const cacheKey = getSkillsCatalogCacheKey(currentDirectory);
|
||||
const now = Date.now();
|
||||
const loadedAt = skillsCatalogLastLoadedAt.get(cacheKey) ?? 0;
|
||||
const hasCachedCatalog = get().sources.length > 0;
|
||||
if (!options?.refresh && hasCachedCatalog && now - loadedAt < SKILLS_CATALOG_LOAD_CACHE_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previous = {
|
||||
sources: get().sources,
|
||||
itemsBySource: get().itemsBySource,
|
||||
pageInfoBySource: get().pageInfoBySource,
|
||||
loadedSourceIds: get().loadedSourceIds,
|
||||
clawdhubHasMoreBySource: get().clawdhubHasMoreBySource,
|
||||
};
|
||||
const inFlight = skillsCatalogLoadInFlight.get(cacheKey);
|
||||
if (!options?.refresh && inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
let lastError: SkillsCatalogResponse['error'] | null = null;
|
||||
const request = (async () => {
|
||||
set({ isLoadingCatalog: true, lastCatalogError: null });
|
||||
|
||||
try {
|
||||
const refresh = options?.refresh ? '?refresh=true' : '';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
|
||||
const previous = {
|
||||
sources: get().sources,
|
||||
itemsBySource: get().itemsBySource,
|
||||
pageInfoBySource: get().pageInfoBySource,
|
||||
loadedSourceIds: get().loadedSourceIds,
|
||||
clawdhubHasMoreBySource: get().clawdhubHasMoreBySource,
|
||||
};
|
||||
|
||||
let lastError: SkillsCatalogResponse['error'] | null = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/config/skills/catalog${refresh}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const refresh = options?.refresh ? '?refresh=true' : '';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
lastError = payload?.error || { kind: 'unknown', message: `Failed to load catalog (${response.status})` };
|
||||
throw new Error(lastError.message);
|
||||
try {
|
||||
const response = await fetch(`/api/config/skills/catalog${refresh}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
lastError = payload?.error || { kind: 'unknown', message: `Failed to load catalog (${response.status})` };
|
||||
throw new Error(lastError.message);
|
||||
}
|
||||
|
||||
const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources;
|
||||
const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {});
|
||||
const pageInfoBySource = options?.refresh ? {} : (get().pageInfoBySource || {});
|
||||
const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {});
|
||||
const clawdhubHasMoreBySource = options?.refresh ? {} : (get().clawdhubHasMoreBySource || {});
|
||||
const currentSelected = get().selectedSourceId;
|
||||
const selectedSourceId =
|
||||
(currentSelected && sources.some((s) => s.id === currentSelected))
|
||||
? currentSelected
|
||||
: (sources[0]?.id ?? null);
|
||||
|
||||
set({
|
||||
sources,
|
||||
itemsBySource,
|
||||
pageInfoBySource,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
selectedSourceId,
|
||||
});
|
||||
|
||||
skillsCatalogLastLoadedAt.set(cacheKey, Date.now());
|
||||
return true;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources;
|
||||
const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {});
|
||||
const pageInfoBySource = options?.refresh ? {} : (get().pageInfoBySource || {});
|
||||
const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {});
|
||||
const clawdhubHasMoreBySource = options?.refresh ? {} : (get().clawdhubHasMoreBySource || {});
|
||||
const currentSelected = get().selectedSourceId;
|
||||
const selectedSourceId =
|
||||
(currentSelected && sources.some((s) => s.id === currentSelected))
|
||||
? currentSelected
|
||||
: (sources[0]?.id ?? null);
|
||||
} catch (error) {
|
||||
lastError = lastError || { kind: 'unknown', message: error instanceof Error ? error.message : String(error) };
|
||||
|
||||
set({
|
||||
sources,
|
||||
itemsBySource,
|
||||
pageInfoBySource,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
selectedSourceId,
|
||||
sources: previous.sources,
|
||||
itemsBySource: previous.itemsBySource,
|
||||
pageInfoBySource: previous.pageInfoBySource,
|
||||
loadedSourceIds: previous.loadedSourceIds,
|
||||
clawdhubHasMoreBySource: previous.clawdhubHasMoreBySource,
|
||||
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
|
||||
});
|
||||
|
||||
return true;
|
||||
return false;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
set({ isLoadingCatalog: false });
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = lastError || { kind: 'unknown', message: error instanceof Error ? error.message : String(error) };
|
||||
|
||||
set({
|
||||
sources: previous.sources,
|
||||
itemsBySource: previous.itemsBySource,
|
||||
pageInfoBySource: previous.pageInfoBySource,
|
||||
loadedSourceIds: previous.loadedSourceIds,
|
||||
clawdhubHasMoreBySource: previous.clawdhubHasMoreBySource,
|
||||
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
|
||||
});
|
||||
})();
|
||||
|
||||
return false;
|
||||
skillsCatalogLoadInFlight.set(cacheKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
set({ isLoadingCatalog: false });
|
||||
skillsCatalogLoadInFlight.delete(cacheKey);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -155,6 +155,14 @@ declare global {
|
||||
|
||||
const CONFIG_EVENT_SOURCE = "useSkillsStore";
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const SKILLS_LOAD_CACHE_TTL_MS = 5000;
|
||||
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
|
||||
const skillsLastLoadedAt = new Map<string, number>();
|
||||
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
const getSkillsCacheKey = (directory: string | null): string => {
|
||||
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
|
||||
};
|
||||
const MAX_HEALTH_WAIT_MS = 20000;
|
||||
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
|
||||
const FAST_HEALTH_POLL_ATTEMPTS = 4;
|
||||
@@ -180,43 +188,67 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
},
|
||||
|
||||
loadSkills: async () => {
|
||||
set({ isLoading: true });
|
||||
const previousSkills = get().skills;
|
||||
let lastError: unknown = null;
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const cacheKey = getSkillsCacheKey(currentDirectory);
|
||||
const now = Date.now();
|
||||
const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0;
|
||||
const hasCachedSkills = get().skills.length > 0;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills${queryParams}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list skills: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawSkills: RawSkillResponse[] = data.skills || [];
|
||||
const skills: DiscoveredSkill[] = rawSkills.map((s) => ({
|
||||
name: s.name,
|
||||
path: s.path,
|
||||
scope: s.scope ?? 'user',
|
||||
source: s.source ?? 'opencode',
|
||||
description: s.sources?.md?.description || '',
|
||||
group: parseSkillGroup(s.path),
|
||||
}));
|
||||
|
||||
set({ skills, isLoading: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const waitMs = 200 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
if (hasCachedSkills && now - loadedAt < SKILLS_LOAD_CACHE_TTL_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error("Failed to load skills:", lastError);
|
||||
set({ skills: previousSkills, isLoading: false });
|
||||
return false;
|
||||
const inFlight = skillsLoadInFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
set({ isLoading: true });
|
||||
const previousSkills = get().skills;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills${queryParams}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list skills: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawSkills: RawSkillResponse[] = data.skills || [];
|
||||
const skills: DiscoveredSkill[] = rawSkills.map((s) => ({
|
||||
name: s.name,
|
||||
path: s.path,
|
||||
scope: s.scope ?? 'user',
|
||||
source: s.source ?? 'opencode',
|
||||
description: s.sources?.md?.description || '',
|
||||
group: parseSkillGroup(s.path),
|
||||
}));
|
||||
|
||||
set({ skills, isLoading: false });
|
||||
skillsLastLoadedAt.set(cacheKey, Date.now());
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const waitMs = 200 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Failed to load skills:", lastError);
|
||||
set({ skills: previousSkills, isLoading: false });
|
||||
return false;
|
||||
})();
|
||||
|
||||
skillsLoadInFlight.set(cacheKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
skillsLoadInFlight.delete(cacheKey);
|
||||
}
|
||||
},
|
||||
|
||||
getSkillDetail: async (name: string) => {
|
||||
|
||||
@@ -471,7 +471,6 @@ interface UIStore {
|
||||
isBottomTerminalExpanded: boolean;
|
||||
bottomTerminalHeight: number;
|
||||
hasManuallyResizedBottomTerminal: boolean;
|
||||
isNavRailExpanded: boolean;
|
||||
isSessionSwitcherOpen: boolean;
|
||||
activeMainTab: MainTab;
|
||||
mainTabGuard: MainTabGuard | null;
|
||||
@@ -589,8 +588,6 @@ interface UIStore {
|
||||
setBottomTerminalOpen: (open: boolean) => void;
|
||||
setBottomTerminalExpanded: (expanded: boolean) => void;
|
||||
setBottomTerminalHeight: (height: number) => void;
|
||||
setNavRailExpanded: (expanded: boolean) => void;
|
||||
toggleNavRail: () => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
||||
@@ -703,7 +700,6 @@ export const useUIStore = create<UIStore>()(
|
||||
isBottomTerminalExpanded: false,
|
||||
bottomTerminalHeight: 300,
|
||||
hasManuallyResizedBottomTerminal: false,
|
||||
isNavRailExpanded: false,
|
||||
isSessionSwitcherOpen: false,
|
||||
activeMainTab: 'chat',
|
||||
mainTabGuard: null,
|
||||
@@ -739,7 +735,7 @@ export const useUIStore = create<UIStore>()(
|
||||
fontSize: 100,
|
||||
terminalFontSize: 13,
|
||||
padding: 100,
|
||||
cornerRadius: 12,
|
||||
cornerRadius: 18,
|
||||
inputBarOffset: 0,
|
||||
favoriteModels: [],
|
||||
hiddenModels: [],
|
||||
@@ -1169,13 +1165,6 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true });
|
||||
},
|
||||
|
||||
setNavRailExpanded: (expanded) => {
|
||||
set({ isNavRailExpanded: expanded });
|
||||
},
|
||||
toggleNavRail: () => {
|
||||
set({ isNavRailExpanded: !get().isNavRailExpanded });
|
||||
},
|
||||
|
||||
setSessionSwitcherOpen: (open) => {
|
||||
set({ isSessionSwitcherOpen: open });
|
||||
},
|
||||
@@ -1827,7 +1816,6 @@ export const useUIStore = create<UIStore>()(
|
||||
isBottomTerminalOpen: state.isBottomTerminalOpen,
|
||||
isBottomTerminalExpanded: state.isBottomTerminalExpanded,
|
||||
bottomTerminalHeight: state.bottomTerminalHeight,
|
||||
isNavRailExpanded: state.isNavRailExpanded,
|
||||
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
|
||||
activeMainTab: state.activeMainTab,
|
||||
sidebarSection: state.sidebarSection,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
export type PermissionAutoAcceptMap = Record<string, boolean>;
|
||||
|
||||
const DIRECTORY_WILDCARD = "*";
|
||||
|
||||
const encodeBase64 = (value: string): string => {
|
||||
try {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
} catch {
|
||||
return btoa(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeDirectory = (value: string | null | undefined): string | null => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = trimmed.replace(/\\/g, "/");
|
||||
if (normalized === "/") {
|
||||
return "/";
|
||||
}
|
||||
return normalized.length > 1 ? normalized.replace(/\/+$/g, "") : normalized;
|
||||
};
|
||||
|
||||
export const directoryAcceptKey = (directory: string): string => `${encodeBase64(directory)}/${DIRECTORY_WILDCARD}`;
|
||||
|
||||
export const sessionAcceptKey = (sessionID: string, directory: string): string => `${encodeBase64(directory)}/${sessionID}`;
|
||||
|
||||
const resolveLineage = (sessionID: string, sessions: Session[]): string[] => {
|
||||
const map = new Map<string, Session>();
|
||||
for (const session of sessions) {
|
||||
map.set(session.id, session);
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current: string | undefined = sessionID;
|
||||
while (current && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
result.push(current);
|
||||
current = map.get(current)?.parentID;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const autoRespondsPermission = (input: {
|
||||
autoAccept: PermissionAutoAcceptMap;
|
||||
sessions: Session[];
|
||||
sessionID: string;
|
||||
directory: string;
|
||||
}): boolean => {
|
||||
const { autoAccept, sessions, sessionID, directory } = input;
|
||||
|
||||
for (const id of resolveLineage(sessionID, sessions)) {
|
||||
const key = sessionAcceptKey(id, directory);
|
||||
if (key in autoAccept) {
|
||||
return autoAccept[key] === true;
|
||||
}
|
||||
|
||||
// Legacy fallback for pre-directory keys.
|
||||
if (id in autoAccept) {
|
||||
return autoAccept[id] === true;
|
||||
}
|
||||
}
|
||||
|
||||
const directoryKey = directoryAcceptKey(directory);
|
||||
if (directoryKey in autoAccept) {
|
||||
return autoAccept[directoryKey] === true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isDirectoryAutoAccepting = (autoAccept: PermissionAutoAcceptMap, directory: string): boolean => {
|
||||
const key = directoryAcceptKey(directory);
|
||||
return autoAccept[key] === true;
|
||||
};
|
||||
Reference in New Issue
Block a user