Files
openchamber/packages/ui/src/stores/useUIStore.ts
T
Ashik Ahmed 934df2b7e6 perf: stability and performance improvements with some minor UI issues resolved (#172)
* Stability and performance improvements. (#1)

## Changelog

### Performance Improvements

- **perf: make terminal creation cwd check async (#9)**  
  Replaced synchronous `fs.existsSync` with `fs.promises.access` in the terminal creation handler to prevent blocking the event loop.  
  Improves throughput under high load.

- **perf: optimize fuzzyMatchScore by avoiding redundant lowercasing (#8)**  
  Renamed `fuzzyMatchScore` to `fuzzyMatchScoreNormalized` and updated it to accept a pre-lowercased query, avoiding repeated string allocations.  
  Updated the call site in `searchFilesystemFiles`.  
  ~25% search performance improvement on large datasets.

- **perf: use async file read in update-install handler (#7)**  
  Replaced `fs.readFileSync` with `await fs.promises.readFile` to avoid blocking the event loop.  

  **Benchmark (10k ops):**
  - Sync: ~95ms (blocking)  
  - Async: ~1124ms (non-blocking)  

  Higher per-call overhead, but better server responsiveness.

- **perf(server): optimize mkdir endpoint with async fs (#6)**  
  Replaced `fs.mkdirSync` with `await fsPromises.mkdir` in `/api/fs/mkdir`.  
  Prevents event-loop blocking and improves concurrent performance.  

  **Result:** ~2× throughput improvement (100 concurrent requests).

- **perf: parallelize fs checks in validateProjectEntries (#4)**  
  Replaced serial `for...of` with `Promise.all + map`.  
  Reduced validation time for 500 projects from ~110ms to ~20ms (~5× speedup).

- **perf: cache getLoginShellPath result to avoid blocking event loop (#5)**  
  Cached `getLoginShellPath` result to avoid repeated `spawnSync` calls (~400ms each).  
  Subsequent calls reduced to <1ms.

---

### Server Fixes

- **fix(server): use async check for terminal restart endpoint (#3)**  
  Replaced `fs.existsSync` with `fs.promises.stat` in `/api/terminal/:sessionId/restart`.  
  Added directory validation for better robustness.

---

### UI & Accessibility

- **feat(ui): add aria-labels to git identities sidebar buttons (#1)**  
  - Added `aria-label="Create new profile"` to the create button  
  - Added `aria-label="Profile actions"` to the dropdown trigger  
  - Added `.Jules/palette.md` for UX/a11y learnings

---

### UI Performance (Bolt)

- ** Bolt: Optimize MessageList re-renders by preserving referential equality (#2)**
- ** Bolt: Optimize MessageList re-renders by preserving referential equality (#11)**
- ** Bolt: Optimize MessageList re-renders by preserving referential equality (#12)**

* stability and improvements (#17)

* feat: add corner radius setting and update snackbar actions

- Add `cornerRadius` to UI store and settings.
- Add Corner Radius slider to Visual Settings section.
- Apply corner radius to ChatInput component.
- Remove default close button from Snackbar (Sonner).
- Add "OK" action button to session deletion toasts.
- Ensure `cornerRadius` setting is visible in OpenChamberPage.

* fix(ui): add missing aria-label to radius slider and verify functionality

- Added `aria-label="Corner radius in pixels"` to the desktop version of the corner radius slider for accessibility.
- Verified functionality and accessibility compliance via script.

* fix(ui): restore input bar offset setting on desktop

- Restored the Input Bar Offset setting to be visible on desktop, not just mobile.
- Verified both Corner Radius and Input Bar Offset sliders are accessible.

* Fix mobile layout for chat input controls (#16)

* Fix mobile layout for chat input controls

- Reduced horizontal gaps in mobile model controls.
- Added max-width constraints to model, variant, and agent labels on mobile to prevent overflow and cramping.
- Optimized spacing for mobile view.

* feat(git): auto-select gitmoji for generated commit messages

When the "Generate commit message" feature is used and gitmoji is enabled, automatically prepend the appropriate gitmoji based on the commit subject keywords.

- Added `KEYWORD_MAP` to map commit types to gitmojis.
- Added `matchGitmojiFromSubject` helper.
- Updated `handleGenerateCommitMessage` to apply the gitmoji.

* feat(git): auto-select gitmoji for generated commit messages

- Added `KEYWORD_MAP` to map commit types to gitmojis.
- Added `matchGitmojiFromSubject` helper.
- Updated `handleGenerateCommitMessage` to apply the gitmoji.
- Fixed mobile layout for model controls.
2026-01-18 14:28:49 +02:00

532 lines
17 KiB
TypeScript

import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import type { SidebarSection } from '@/constants/sidebar';
import { getSafeStorage } from './utils/safeStorage';
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal' | 'files';
export type EventStreamStatus =
| 'idle'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'paused'
| 'offline'
| 'error';
interface UIStore {
theme: 'light' | 'dark' | 'system';
isMultiRunLauncherOpen: boolean;
multiRunLauncherPrefillPrompt: string;
isSidebarOpen: boolean;
sidebarWidth: number;
hasManuallyResizedLeftSidebar: boolean;
isSessionSwitcherOpen: boolean;
activeMainTab: MainTab;
pendingDiffFile: string | null;
isMobile: boolean;
isKeyboardOpen: boolean;
isCommandPaletteOpen: boolean;
isHelpDialogOpen: boolean;
isAboutDialogOpen: boolean;
isSessionCreateDialogOpen: boolean;
isSettingsDialogOpen: boolean;
isModelSelectorOpen: boolean;
sidebarSection: SidebarSection;
eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null;
showReasoningTraces: boolean;
autoDeleteEnabled: boolean;
autoDeleteAfterDays: number;
autoDeleteLastRunAt: number | null;
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
fontSize: number;
padding: number;
cornerRadius: number;
inputBarOffset: number;
favoriteModels: Array<{ providerID: string; modelID: string }>;
recentModels: Array<{ providerID: string; modelID: string }>;
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
diffWrapLines: boolean;
diffViewMode: 'single' | 'stacked';
isTimelineDialogOpen: boolean;
nativeNotificationsEnabled: boolean;
notificationMode: 'always' | 'hidden-only';
setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSidebarWidth: (width: number) => void;
setSessionSwitcherOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
setPendingDiffFile: (filePath: string | null) => void;
navigateToDiff: (filePath: string) => void;
consumePendingDiffFile: () => string | null;
setIsMobile: (isMobile: boolean) => void;
toggleCommandPalette: () => void;
setCommandPaletteOpen: (open: boolean) => void;
toggleHelpDialog: () => void;
setHelpDialogOpen: (open: boolean) => void;
setAboutDialogOpen: (open: boolean) => void;
setSessionCreateDialogOpen: (open: boolean) => void;
setSettingsDialogOpen: (open: boolean) => void;
setModelSelectorOpen: (open: boolean) => void;
applyTheme: () => void;
setSidebarSection: (section: SidebarSection) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void;
setAutoDeleteEnabled: (value: boolean) => void;
setAutoDeleteAfterDays: (days: number) => void;
setAutoDeleteLastRunAt: (timestamp: number | null) => void;
setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void;
setFontSize: (size: number) => void;
setPadding: (size: number) => void;
setCornerRadius: (radius: number) => void;
setInputBarOffset: (offset: number) => void;
setKeyboardOpen: (open: boolean) => void;
applyTypography: () => void;
applyPadding: () => void;
updateProportionalSidebarWidths: () => void;
toggleFavoriteModel: (providerID: string, modelID: string) => void;
isFavoriteModel: (providerID: string, modelID: string) => boolean;
addRecentModel: (providerID: string, modelID: string) => void;
setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void;
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
setDiffWrapLines: (wrap: boolean) => void;
setDiffViewMode: (mode: 'single' | 'stacked') => void;
setMultiRunLauncherOpen: (open: boolean) => void;
setTimelineDialogOpen: (open: boolean) => void;
setNativeNotificationsEnabled: (value: boolean) => void;
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
openMultiRunLauncher: () => void;
openMultiRunLauncherWithPrompt: (prompt: string) => void;
}
export const useUIStore = create<UIStore>()(
devtools(
persist(
(set, get) => ({
theme: 'system',
isMultiRunLauncherOpen: false,
multiRunLauncherPrefillPrompt: '',
isSidebarOpen: true,
sidebarWidth: 264,
hasManuallyResizedLeftSidebar: false,
isSessionSwitcherOpen: false,
activeMainTab: 'chat',
pendingDiffFile: null,
isMobile: false,
isKeyboardOpen: false,
isCommandPaletteOpen: false,
isHelpDialogOpen: false,
isAboutDialogOpen: false,
isSessionCreateDialogOpen: false,
isSettingsDialogOpen: false,
isModelSelectorOpen: false,
sidebarSection: 'sessions',
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: false,
autoDeleteEnabled: false,
autoDeleteAfterDays: 30,
autoDeleteLastRunAt: null,
toolCallExpansion: 'collapsed',
fontSize: 100,
padding: 100,
cornerRadius: 12,
inputBarOffset: 0,
favoriteModels: [],
recentModels: [],
diffLayoutPreference: 'dynamic',
diffFileLayout: {},
diffWrapLines: false,
diffViewMode: 'single',
isTimelineDialogOpen: false,
nativeNotificationsEnabled: false,
notificationMode: 'hidden-only',
setTheme: (theme) => {
set({ theme });
get().applyTheme();
},
toggleSidebar: () => {
set((state) => {
const newOpen = !state.isSidebarOpen;
if (newOpen && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
return {
isSidebarOpen: newOpen,
sidebarWidth: proportionalWidth,
hasManuallyResizedLeftSidebar: false
};
}
return { isSidebarOpen: newOpen };
});
},
setSidebarOpen: (open) => {
set(() => {
if (open && typeof window !== 'undefined') {
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
return {
isSidebarOpen: open,
sidebarWidth: proportionalWidth,
hasManuallyResizedLeftSidebar: false
};
}
return { isSidebarOpen: open };
});
},
setSidebarWidth: (width) => {
set({ sidebarWidth: width, hasManuallyResizedLeftSidebar: true });
},
setSessionSwitcherOpen: (open) => {
set({ isSessionSwitcherOpen: open });
},
setActiveMainTab: (tab) => {
set({ activeMainTab: tab });
},
setPendingDiffFile: (filePath) => {
set({ pendingDiffFile: filePath });
},
navigateToDiff: (filePath) => {
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
},
consumePendingDiffFile: () => {
const { pendingDiffFile } = get();
if (pendingDiffFile) {
set({ pendingDiffFile: null });
}
return pendingDiffFile;
},
setIsMobile: (isMobile) => {
set({ isMobile });
},
toggleCommandPalette: () => {
set((state) => ({ isCommandPaletteOpen: !state.isCommandPaletteOpen }));
},
setCommandPaletteOpen: (open) => {
set({ isCommandPaletteOpen: open });
},
toggleHelpDialog: () => {
set((state) => ({ isHelpDialogOpen: !state.isHelpDialogOpen }));
},
setHelpDialogOpen: (open) => {
set({ isHelpDialogOpen: open });
},
setAboutDialogOpen: (open) => {
set({ isAboutDialogOpen: open });
},
setSessionCreateDialogOpen: (open) => {
set({ isSessionCreateDialogOpen: open });
},
setSettingsDialogOpen: (open) => {
set({ isSettingsDialogOpen: open });
},
setModelSelectorOpen: (open) => {
set({ isModelSelectorOpen: open });
},
setSidebarSection: (section) => {
set({ sidebarSection: section });
},
setEventStreamStatus: (status, hint) => {
set({
eventStreamStatus: status,
eventStreamHint: hint ?? null,
});
},
setShowReasoningTraces: (value) => {
set({ showReasoningTraces: value });
},
setAutoDeleteEnabled: (value) => {
set({ autoDeleteEnabled: value });
},
setAutoDeleteAfterDays: (days) => {
const clampedDays = Math.max(1, Math.min(365, days));
set({ autoDeleteAfterDays: clampedDays });
},
setAutoDeleteLastRunAt: (timestamp) => {
set({ autoDeleteLastRunAt: timestamp });
},
setToolCallExpansion: (value) => {
set({ toolCallExpansion: value });
},
setFontSize: (size) => {
// Clamp between 50% and 200%
const clampedSize = Math.max(50, Math.min(200, size));
set({ fontSize: clampedSize });
get().applyTypography();
},
setPadding: (size) => {
// Clamp between 50% and 200%
const clampedSize = Math.max(50, Math.min(200, size));
set({ padding: clampedSize });
get().applyPadding();
},
setCornerRadius: (radius) => {
set({ cornerRadius: radius });
},
applyTypography: () => {
const { fontSize } = get();
const root = document.documentElement;
// 100 = default (1.0x), 50 = half size (0.5x), 200 = double (2.0x)
const scale = fontSize / 100;
const entries = Object.entries(SEMANTIC_TYPOGRAPHY) as Array<[SemanticTypographyKey, string]>;
// Default must be SEMANTIC_TYPOGRAPHY (from CSS). Remove overrides.
if (scale === 1) {
for (const [key] of entries) {
root.style.removeProperty(getTypographyVariable(key));
}
return;
}
for (const [key, baseValue] of entries) {
const numericValue = parseFloat(baseValue);
if (!Number.isFinite(numericValue)) {
continue;
}
root.style.setProperty(getTypographyVariable(key), `${numericValue * scale}rem`);
}
},
applyPadding: () => {
const { padding } = get();
const root = document.documentElement;
const scale = padding / 100;
if (scale === 1) {
root.style.removeProperty('--padding-scale');
root.style.removeProperty('--line-height-tight');
root.style.removeProperty('--line-height-normal');
root.style.removeProperty('--line-height-relaxed');
root.style.removeProperty('--line-height-loose');
return;
}
// Apply padding as a percentage scale with non-linear scaling
// Use square root for more natural scaling at extremes
const adjustedScale = Math.sqrt(scale);
// Set the CSS custom property that all spacing tokens reference
root.style.setProperty('--padding-scale', adjustedScale.toString());
// Dampened line-height scaling at extremes
const lineHeightScale = 1 + (scale - 1) * 0.15;
root.style.setProperty('--line-height-tight', (1.25 * lineHeightScale).toFixed(3));
root.style.setProperty('--line-height-normal', (1.5 * lineHeightScale).toFixed(3));
root.style.setProperty('--line-height-relaxed', (1.625 * lineHeightScale).toFixed(3));
root.style.setProperty('--line-height-loose', (2 * lineHeightScale).toFixed(3));
},
setDiffLayoutPreference: (mode) => {
set({ diffLayoutPreference: mode });
},
setDiffFileLayout: (filePath, mode) => {
set((state) => ({
diffFileLayout: {
...state.diffFileLayout,
[filePath]: mode,
},
}));
},
setDiffWrapLines: (wrap) => {
set({ diffWrapLines: wrap });
},
setDiffViewMode: (mode) => {
set({ diffViewMode: mode });
},
setInputBarOffset: (offset) => {
set({ inputBarOffset: offset });
},
setKeyboardOpen: (open) => {
set({ isKeyboardOpen: open });
},
toggleFavoriteModel: (providerID, modelID) => {
set((state) => {
const exists = state.favoriteModels.some(
(fav) => fav.providerID === providerID && fav.modelID === modelID
);
if (exists) {
// Remove from favorites
return {
favoriteModels: state.favoriteModels.filter(
(fav) => !(fav.providerID === providerID && fav.modelID === modelID)
),
};
} else {
// Add to favorites (newest first)
return {
favoriteModels: [{ providerID, modelID }, ...state.favoriteModels],
};
}
});
},
isFavoriteModel: (providerID, modelID) => {
const { favoriteModels } = get();
return favoriteModels.some(
(fav) => fav.providerID === providerID && fav.modelID === modelID
);
},
addRecentModel: (providerID, modelID) => {
set((state) => {
// Remove existing instance if any
const filtered = state.recentModels.filter(
(m) => !(m.providerID === providerID && m.modelID === modelID)
);
// Add to front, limit to 5
return {
recentModels: [{ providerID, modelID }, ...filtered].slice(0, 5),
};
});
},
updateProportionalSidebarWidths: () => {
if (typeof window === 'undefined') {
return;
}
set((state) => {
const updates: Partial<UIStore> = {};
if (state.isSidebarOpen && !state.hasManuallyResizedLeftSidebar) {
updates.sidebarWidth = Math.floor(window.innerWidth * 0.2);
}
return updates;
});
},
applyTheme: () => {
const { theme } = get();
const root = document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
},
setMultiRunLauncherOpen: (open) => {
set((state) => ({
isMultiRunLauncherOpen: open,
multiRunLauncherPrefillPrompt: open ? state.multiRunLauncherPrefillPrompt : '',
}));
},
openMultiRunLauncher: () => {
set({
isMultiRunLauncherOpen: true,
multiRunLauncherPrefillPrompt: '',
isSessionSwitcherOpen: false,
});
},
openMultiRunLauncherWithPrompt: (prompt) => {
set({
isMultiRunLauncherOpen: true,
multiRunLauncherPrefillPrompt: prompt,
isSessionSwitcherOpen: false,
});
},
setTimelineDialogOpen: (open) => {
set({ isTimelineDialogOpen: open });
},
setNativeNotificationsEnabled: (value) => {
set({ nativeNotificationsEnabled: value });
},
setNotificationMode: (mode) => {
set({ notificationMode: mode });
},
}),
{
name: 'ui-store',
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
theme: state.theme,
isSidebarOpen: state.isSidebarOpen,
sidebarWidth: state.sidebarWidth,
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
activeMainTab: state.activeMainTab,
sidebarSection: state.sidebarSection,
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
isSettingsDialogOpen: state.isSettingsDialogOpen,
showReasoningTraces: state.showReasoningTraces,
autoDeleteEnabled: state.autoDeleteEnabled,
autoDeleteAfterDays: state.autoDeleteAfterDays,
autoDeleteLastRunAt: state.autoDeleteLastRunAt,
toolCallExpansion: state.toolCallExpansion,
fontSize: state.fontSize,
padding: state.padding,
cornerRadius: state.cornerRadius,
favoriteModels: state.favoriteModels,
recentModels: state.recentModels,
diffLayoutPreference: state.diffLayoutPreference,
diffWrapLines: state.diffWrapLines,
diffViewMode: state.diffViewMode,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
})
}
),
{
name: 'ui-store'
}
)
);