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:
Bohdan Triapitsyn
2026-03-20 01:01:03 +02:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 359879153a
commit 321cc7252a
222 changed files with 8575 additions and 5456 deletions
File diff suppressed because it is too large Load Diff
+145 -25
View File
@@ -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,
};
},
}
+58 -17
View File
@@ -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;
+3 -1
View File
@@ -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>;
+117 -66
View File
@@ -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) => {
+115 -68
View File
@@ -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) => {
+156 -88
View File
@@ -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' });
}
},
+48 -16
View File
@@ -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);
+166 -16
View File
@@ -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;
+87 -54
View File
@@ -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);
}
},
+66 -34
View File
@@ -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) => {
+1 -13
View File
@@ -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;
};