## 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>
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
|
|
import type {
|
|
SkillsCatalogResponse,
|
|
SkillsCatalogSource,
|
|
SkillsCatalogItem,
|
|
SkillsRepoScanRequest,
|
|
SkillsRepoScanResponse,
|
|
SkillsInstallRequest,
|
|
SkillsInstallResponse,
|
|
SkillsInstallError,
|
|
SkillsCatalogSourceResponse,
|
|
} from '@/lib/api/types';
|
|
|
|
import { refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore';
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
|
import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate';
|
|
|
|
const FALLBACK_SOURCES: SkillsCatalogSource[] = [
|
|
{
|
|
id: 'anthropic',
|
|
label: 'Anthropic',
|
|
description: "Anthropic's public skills repository",
|
|
source: 'anthropics/skills',
|
|
defaultSubpath: 'skills',
|
|
sourceType: 'github',
|
|
},
|
|
{
|
|
id: 'clawdhub',
|
|
label: 'ClawdHub',
|
|
description: 'Community skill registry with vector search',
|
|
source: 'clawdhub:registry',
|
|
sourceType: 'clawdhub',
|
|
},
|
|
];
|
|
|
|
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) {
|
|
return opencodeDirectory;
|
|
}
|
|
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const store = (window as any).__zustand_directory_store__;
|
|
if (store) {
|
|
return store.getState().currentDirectory;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export interface SkillsCatalogState {
|
|
sources: SkillsCatalogSource[];
|
|
itemsBySource: Record<string, SkillsCatalogItem[]>;
|
|
selectedSourceId: string | null;
|
|
pageInfoBySource: Record<string, { nextCursor?: string | null }>;
|
|
loadedSourceIds: Record<string, boolean>;
|
|
clawdhubHasMoreBySource: Record<string, boolean>;
|
|
|
|
isLoadingCatalog: boolean;
|
|
isLoadingSource: boolean;
|
|
isLoadingMore: boolean;
|
|
isScanning: boolean;
|
|
isInstalling: boolean;
|
|
|
|
lastCatalogError: SkillsCatalogResponse['error'] | null;
|
|
lastScanError: SkillsRepoScanResponse['error'] | null;
|
|
lastInstallError: SkillsInstallError | null;
|
|
|
|
scanResults: SkillsCatalogItem[] | null;
|
|
|
|
setSelectedSource: (id: string | null) => void;
|
|
|
|
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
|
|
loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>;
|
|
loadMoreClawdHub: () => Promise<boolean>;
|
|
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
|
|
installSkills: (request: SkillsInstallRequest, options?: { directory?: string | null }) => Promise<SkillsInstallResponse>;
|
|
}
|
|
|
|
export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
|
devtools(
|
|
(set, get) => ({
|
|
sources: FALLBACK_SOURCES,
|
|
itemsBySource: {},
|
|
selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null,
|
|
pageInfoBySource: {},
|
|
loadedSourceIds: {},
|
|
clawdhubHasMoreBySource: {},
|
|
|
|
isLoadingCatalog: false,
|
|
isLoadingSource: false,
|
|
isLoadingMore: false,
|
|
isScanning: false,
|
|
isInstalling: false,
|
|
|
|
lastCatalogError: null,
|
|
lastScanError: null,
|
|
lastInstallError: null,
|
|
|
|
scanResults: null,
|
|
|
|
setSelectedSource: (id) => set({ selectedSourceId: id }),
|
|
|
|
loadCatalog: async (options) => {
|
|
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 inFlight = skillsCatalogLoadInFlight.get(cacheKey);
|
|
if (!options?.refresh && inFlight) {
|
|
return inFlight;
|
|
}
|
|
|
|
const request = (async () => {
|
|
set({ isLoadingCatalog: true, lastCatalogError: null });
|
|
|
|
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 refresh = options?.refresh ? '?refresh=true' : '';
|
|
const controller = new AbortController();
|
|
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
|
|
|
|
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);
|
|
}
|
|
} 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;
|
|
} finally {
|
|
set({ isLoadingCatalog: false });
|
|
}
|
|
})();
|
|
|
|
skillsCatalogLoadInFlight.set(cacheKey, request);
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
skillsCatalogLoadInFlight.delete(cacheKey);
|
|
}
|
|
},
|
|
|
|
loadSource: async (sourceId, options) => {
|
|
if (!sourceId) {
|
|
return false;
|
|
}
|
|
|
|
set({ isLoadingSource: true, lastCatalogError: null });
|
|
|
|
try {
|
|
const currentDirectory = getCurrentDirectory();
|
|
const refresh = options?.refresh ? '&refresh=true' : '';
|
|
const queryParams = currentDirectory
|
|
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
|
|
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
|
|
|
|
const response = await fetch(`/api/config/skills/catalog/source${queryParams}`, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
|
|
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
|
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
|
|
if (!response.ok || (!payload?.ok && !hasItems)) {
|
|
const fallback = await fetch(`/api/config/skills/catalog${queryParams}`, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
|
|
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
|
|
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
|
|
set((state) => ({
|
|
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
|
|
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor: null } },
|
|
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
|
clawdhubHasMoreBySource: { ...state.clawdhubHasMoreBySource, [sourceId]: false },
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
set({
|
|
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const items = payload?.items || [];
|
|
const nextCursor = payload?.nextCursor ?? null;
|
|
|
|
set((state) => ({
|
|
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
|
|
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor } },
|
|
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
|
clawdhubHasMoreBySource: {
|
|
...state.clawdhubHasMoreBySource,
|
|
[sourceId]: items.length > 0,
|
|
},
|
|
}));
|
|
|
|
return true;
|
|
} catch (error) {
|
|
set({
|
|
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
|
|
});
|
|
return false;
|
|
} finally {
|
|
set({ isLoadingSource: false });
|
|
}
|
|
},
|
|
|
|
loadMoreClawdHub: async () => {
|
|
const selectedSourceId = get().selectedSourceId;
|
|
if (!selectedSourceId) {
|
|
return false;
|
|
}
|
|
|
|
const pageInfo = get().pageInfoBySource[selectedSourceId];
|
|
const cursor = pageInfo?.nextCursor || null;
|
|
|
|
set({ isLoadingMore: true });
|
|
try {
|
|
const currentDirectory = getCurrentDirectory();
|
|
const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`];
|
|
if (currentDirectory) {
|
|
parts.push(`directory=${encodeURIComponent(currentDirectory)}`);
|
|
}
|
|
if (cursor) {
|
|
parts.push(`cursor=${encodeURIComponent(cursor)}`);
|
|
}
|
|
const queryParams = `?${parts.join('&')}`;
|
|
|
|
const response = await fetch(`/api/config/skills/catalog/source${queryParams}`, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
|
|
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
|
if (!response.ok || !payload?.ok) {
|
|
return false;
|
|
}
|
|
|
|
const nextCursor = payload.nextCursor ?? null;
|
|
const currentItems = get().itemsBySource[selectedSourceId] || [];
|
|
const items = payload.items || [];
|
|
const merged = new Map(currentItems.map((item) => [`${item.sourceId}:${item.skillDir}`, item]));
|
|
let newCount = 0;
|
|
|
|
for (const item of items) {
|
|
const key = `${item.sourceId}:${item.skillDir}`;
|
|
if (!merged.has(key)) {
|
|
newCount += 1;
|
|
}
|
|
merged.set(key, item);
|
|
}
|
|
|
|
const noMore = items.length === 0 || newCount === 0;
|
|
|
|
set((state) => ({
|
|
itemsBySource: {
|
|
...state.itemsBySource,
|
|
[selectedSourceId]: Array.from(merged.values()),
|
|
},
|
|
pageInfoBySource: {
|
|
...state.pageInfoBySource,
|
|
[selectedSourceId]: { nextCursor },
|
|
},
|
|
clawdhubHasMoreBySource: {
|
|
...state.clawdhubHasMoreBySource,
|
|
[selectedSourceId]: !noMore,
|
|
},
|
|
}));
|
|
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
set({ isLoadingMore: false });
|
|
}
|
|
},
|
|
|
|
scanRepo: async (request) => {
|
|
set({ isScanning: true, lastScanError: null, scanResults: null });
|
|
try {
|
|
const currentDirectory = getCurrentDirectory();
|
|
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
|
|
|
const response = await fetch(`/api/config/skills/scan${queryParams}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify(request),
|
|
});
|
|
|
|
const payload = (await response.json().catch(() => null)) as SkillsRepoScanResponse | null;
|
|
if (!response.ok || !payload) {
|
|
const error = payload?.error || { kind: 'unknown', message: 'Failed to scan repository' };
|
|
set({ lastScanError: error });
|
|
return { ok: false, error };
|
|
}
|
|
|
|
if (!payload.ok) {
|
|
set({ lastScanError: payload.error || { kind: 'unknown', message: 'Failed to scan repository' } });
|
|
return payload;
|
|
}
|
|
|
|
set({ scanResults: payload.items || [] });
|
|
return payload;
|
|
} finally {
|
|
set({ isScanning: false });
|
|
}
|
|
},
|
|
|
|
installSkills: async (request, options) => {
|
|
startConfigUpdate('Installing skills…');
|
|
set({ isInstalling: true, lastInstallError: null });
|
|
let requiresReload = false;
|
|
try {
|
|
const directoryOverride = typeof options?.directory === 'string' && options.directory.trim().length > 0
|
|
? options.directory.trim()
|
|
: null;
|
|
const currentDirectory = directoryOverride ?? getCurrentDirectory();
|
|
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
|
|
|
const response = await fetch(`/api/config/skills/install${queryParams}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify(request),
|
|
});
|
|
|
|
const payload = (await response.json().catch(() => null)) as SkillsInstallResponse | null;
|
|
if (!payload) {
|
|
const error = { kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError;
|
|
set({ lastInstallError: error });
|
|
updateConfigUpdateMessage('Failed to install skills. Please retry.');
|
|
return { ok: false, error };
|
|
}
|
|
|
|
if (!response.ok || !payload.ok) {
|
|
const error = payload.error || ({ kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError);
|
|
set({ lastInstallError: error });
|
|
updateConfigUpdateMessage(error.message || 'Failed to install skills. Please retry.');
|
|
return { ok: false, error };
|
|
}
|
|
|
|
if (payload.requiresReload) {
|
|
requiresReload = true;
|
|
await refreshSkillsAfterOpenCodeRestart({
|
|
message: payload.message,
|
|
delayMs: payload.reloadDelayMs,
|
|
});
|
|
} else {
|
|
updateConfigUpdateMessage(payload.message || 'Refreshing skills…');
|
|
void useSkillsStore.getState().loadSkills();
|
|
}
|
|
|
|
return payload;
|
|
} catch (error) {
|
|
const err = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) } as SkillsInstallError;
|
|
set({ lastInstallError: err });
|
|
updateConfigUpdateMessage('Failed to install skills. Please retry.');
|
|
return { ok: false, error: err };
|
|
} finally {
|
|
set({ isInstalling: false });
|
|
if (!requiresReload) {
|
|
finishConfigUpdate();
|
|
}
|
|
}
|
|
},
|
|
}),
|
|
{ name: 'skills-catalog-store' }
|
|
)
|
|
);
|