feat(ui): polish chat and git workflows with mobile UX and reliability fixes (#569)
* feat: add chat option for user message rendering mode * feat: add chat option to toggle sticky user header * feat(ui): overhaul context panel with reusable tabs and embedded session chat Enable parallel context workflows with persistent tabbed views and isolated session chat while reducing resize and background runtime overhead. * feat: polish context panel and git sidebar tabs Refined context panel tab behavior and visuals for smoother switching and resizing Reused the new tabs component in right sidebar and git sidebar with fit layout Improved git section spacing, selection controls, and bulk revert confirmation flow * feat: open diff files in editor at changed lines Add edit actions in diff views to open files at the first changed line Support per-file open-in-editor from All Files headers and icon-only action in single-file view Improve file jump UX with load-aware navigation and reduced visual blink during line targeting * fix: stabilize pill tabs and prevent git commit pathspec failures Unified sortable tab variants to match animated styling behavior with responsive spacing and cleaner sidebar chrome Fixed active tab pill measurement so size/position recalculates correctly when dropdowns reopen Commit API now filters stale file paths before staging to avoid pathspec errors on deleted files * fix: align user message action row spacing and hover behavior * fix: persist user message view preferences in settings Save plain-text and sticky-header toggles to settings.json when changed Restore both chat display preferences from settings.json on startup Validate and accept both preference fields in the settings API * fix: improve git and sidebar tab layout on mobile * fix: refine mobile user message action row spacing Show mobile user-message actions in a consistent external row for sticky and non-sticky modes Tune button row height and vertical position to match both mobile variants Reduce sticky-header gradient tail and tighten assistant gap after user messages * fix: improve chat action hover zones and mobile top shadow logic Expand desktop trigger area so user action buttons reveal across the full row Add sticky-header phantom hover row so inline actions appear from the whole button lane Hide chat top scroll shadow on mobile only when sticky user headers are enabled * fix: remove commit message input scrollbar flicker Added optional scrollbar class support to shared textarea wrapper. Disabled overlay scrollbar for Git commit message input. Kept auto-resize behavior while preventing one-line empty-state micro-scroll. * feat: make model provider groups collapsible in selector Add collapsible provider headers in the chat model dropdown Persist expanded/collapsed provider state across sessions Refine provider header UX with inline chevrons and no hover highlight * feat: arrange chat settings into a compact two-column layout Places User Message Rendering next to Mermaid Rendering. Places Diff Layout next to Diff View Mode. Reduces right-column spacing to better match other settings sections. * fix: show worktree branch edit controls in draft sessions Detect worktree mode from current directory when session metadata is not yet bound Enable immediate branch rename UI in Git sidebar without session switching * feat: add beta badge to side panel menu action
This commit is contained in:
committed by
GitHub
parent
73e533a315
commit
b4cd16f55b
@@ -7,18 +7,41 @@ import type { ShortcutCombo } from '@/lib/shortcuts';
|
||||
|
||||
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
|
||||
export type RightSidebarTab = 'git' | 'files';
|
||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan';
|
||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat';
|
||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
|
||||
type ContextPanelTab = {
|
||||
id: string;
|
||||
mode: ContextPanelMode;
|
||||
targetPath: string | null;
|
||||
dedupeKey: string;
|
||||
label: string | null;
|
||||
touchedAt: number;
|
||||
};
|
||||
|
||||
type ContextPanelTabDescriptor = {
|
||||
mode: ContextPanelMode;
|
||||
targetPath?: string | null;
|
||||
dedupeKey?: string | null;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
type ContextPanelDirectoryState = {
|
||||
isOpen: boolean;
|
||||
expanded: boolean;
|
||||
mode: ContextPanelMode | null;
|
||||
targetPath: string | null;
|
||||
tabs: ContextPanelTab[];
|
||||
activeTabId: string | null;
|
||||
width: number;
|
||||
touchedAt: number;
|
||||
};
|
||||
|
||||
type PendingFileNavigation = {
|
||||
path: string;
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
|
||||
export type MainTabGuard = (nextTab: MainTab) => boolean;
|
||||
export type EventStreamStatus =
|
||||
| 'idle'
|
||||
@@ -67,6 +90,8 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
|
||||
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
|
||||
const CONTEXT_PANEL_MIN_WIDTH = 360;
|
||||
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
||||
const CONTEXT_PANEL_MAX_TABS = 12;
|
||||
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
|
||||
const LEFT_SIDEBAR_MIN_WIDTH = 300;
|
||||
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
|
||||
|
||||
@@ -97,21 +122,319 @@ const clampContextPanelWidth = (width: number): number => {
|
||||
return Math.min(CONTEXT_PANEL_MAX_WIDTH, Math.max(CONTEXT_PANEL_MIN_WIDTH, Math.round(width)));
|
||||
};
|
||||
|
||||
const normalizeContextTargetPath = (value: string | null | undefined): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\\/g, '/');
|
||||
};
|
||||
|
||||
const normalizeContextTabLabel = (value: string | null | undefined): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.length > CONTEXT_PANEL_MAX_LABEL_LENGTH
|
||||
? trimmed.slice(0, CONTEXT_PANEL_MAX_LABEL_LENGTH)
|
||||
: trimmed;
|
||||
};
|
||||
|
||||
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
|
||||
if (mode === 'file') {
|
||||
return targetPath || mode;
|
||||
}
|
||||
|
||||
return mode;
|
||||
};
|
||||
|
||||
const normalizeContextPanelTabDedupeKey = (
|
||||
mode: ContextPanelMode,
|
||||
targetPath: string | null,
|
||||
dedupeKey: string | null | undefined,
|
||||
): string => {
|
||||
if (typeof dedupeKey === 'string') {
|
||||
const trimmed = dedupeKey.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return buildDefaultContextPanelTabDedupeKey(mode, targetPath);
|
||||
};
|
||||
|
||||
const buildContextPanelTabID = (mode: ContextPanelMode, dedupeKey: string): string => {
|
||||
return dedupeKey === mode ? mode : `${mode}:${dedupeKey}`;
|
||||
};
|
||||
|
||||
const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPanelTab => {
|
||||
const normalizedTargetPath = normalizeContextTargetPath(descriptor.targetPath);
|
||||
const dedupeKey = normalizeContextPanelTabDedupeKey(
|
||||
descriptor.mode,
|
||||
normalizedTargetPath,
|
||||
descriptor.dedupeKey,
|
||||
);
|
||||
return {
|
||||
id: buildContextPanelTabID(descriptor.mode, dedupeKey),
|
||||
mode: descriptor.mode,
|
||||
targetPath: normalizedTargetPath,
|
||||
dedupeKey,
|
||||
label: normalizeContextTabLabel(descriptor.label),
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const clampContextPanelTabs = (tabs: ContextPanelTab[], maxTabs: number, activeTabId: string | null): ContextPanelTab[] => {
|
||||
if (tabs.length <= maxTabs) {
|
||||
return tabs;
|
||||
}
|
||||
|
||||
const tabsByTouch = [...tabs].sort((a, b) => a.touchedAt - b.touchedAt);
|
||||
const removable = tabsByTouch.filter((tab) => tab.id !== activeTabId);
|
||||
const removeCount = tabs.length - maxTabs;
|
||||
if (removeCount <= 0 || removable.length === 0) {
|
||||
return tabs.slice(-maxTabs);
|
||||
}
|
||||
|
||||
const removeSet = new Set(removable.slice(0, removeCount).map((tab) => tab.id));
|
||||
return tabs.filter((tab) => !removeSet.has(tab.id));
|
||||
};
|
||||
|
||||
const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
if (!Array.isArray(tabs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: ContextPanelTab[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of tabs) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = entry as {
|
||||
mode?: unknown;
|
||||
targetPath?: unknown;
|
||||
dedupeKey?: unknown;
|
||||
label?: unknown;
|
||||
touchedAt?: unknown;
|
||||
};
|
||||
|
||||
if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetPath = normalizeContextTargetPath(typeof candidate.targetPath === 'string' ? candidate.targetPath : null);
|
||||
const dedupeKey = normalizeContextPanelTabDedupeKey(
|
||||
candidate.mode,
|
||||
targetPath,
|
||||
typeof candidate.dedupeKey === 'string' ? candidate.dedupeKey : null,
|
||||
);
|
||||
const id = buildContextPanelTabID(candidate.mode, dedupeKey);
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
result.push({
|
||||
id,
|
||||
mode: candidate.mode,
|
||||
targetPath,
|
||||
dedupeKey,
|
||||
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
|
||||
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
||||
? candidate.touchedAt
|
||||
: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveActiveContextPanelTabID = (tabs: ContextPanelTab[], activeTabId: string | null): string | null => {
|
||||
if (activeTabId && tabs.some((tab) => tab.id === activeTabId)) {
|
||||
return activeTabId;
|
||||
}
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tabs[tabs.length - 1].id;
|
||||
};
|
||||
|
||||
const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanelDirectoryState => {
|
||||
if (prev) {
|
||||
return { ...prev, touchedAt: Date.now() };
|
||||
const tabs = sanitizeContextPanelTabs(prev.tabs);
|
||||
const activeTabId = resolveActiveContextPanelTabID(tabs, prev.activeTabId);
|
||||
return {
|
||||
...prev,
|
||||
tabs,
|
||||
activeTabId,
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen: false,
|
||||
expanded: false,
|
||||
mode: null,
|
||||
targetPath: null,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
width: CONTEXT_PANEL_DEFAULT_WIDTH,
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const upsertContextPanelTab = (
|
||||
current: ContextPanelDirectoryState,
|
||||
descriptor: ContextPanelTabDescriptor,
|
||||
): ContextPanelDirectoryState => {
|
||||
const nextTab = createContextPanelTab(descriptor);
|
||||
const existingIndex = current.tabs.findIndex((tab) => tab.id === nextTab.id);
|
||||
const tabs = existingIndex === -1
|
||||
? [...current.tabs, nextTab]
|
||||
: current.tabs.map((tab, index) => (index === existingIndex
|
||||
? {
|
||||
...tab,
|
||||
mode: nextTab.mode,
|
||||
targetPath: nextTab.targetPath,
|
||||
dedupeKey: nextTab.dedupeKey,
|
||||
label: nextTab.label,
|
||||
touchedAt: Date.now(),
|
||||
}
|
||||
: tab));
|
||||
|
||||
const activeTabId = nextTab.id;
|
||||
const clampedTabs = clampContextPanelTabs(tabs, CONTEXT_PANEL_MAX_TABS, activeTabId);
|
||||
|
||||
return {
|
||||
...current,
|
||||
isOpen: true,
|
||||
tabs: clampedTabs,
|
||||
activeTabId: resolveActiveContextPanelTabID(clampedTabs, activeTabId),
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const closeContextPanelTab = (
|
||||
current: ContextPanelDirectoryState,
|
||||
tabID: string,
|
||||
): ContextPanelDirectoryState => {
|
||||
const nextTabs = current.tabs.filter((tab) => tab.id !== tabID);
|
||||
const nextActiveTabId = current.activeTabId === tabID
|
||||
? (nextTabs[nextTabs.length - 1]?.id ?? null)
|
||||
: resolveActiveContextPanelTabID(nextTabs, current.activeTabId);
|
||||
|
||||
return {
|
||||
...current,
|
||||
tabs: nextTabs,
|
||||
activeTabId: nextActiveTabId,
|
||||
isOpen: nextTabs.length > 0 ? current.isOpen : false,
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const reorderContextPanelTabs = (
|
||||
current: ContextPanelDirectoryState,
|
||||
activeTabID: string,
|
||||
overTabID: string,
|
||||
): ContextPanelDirectoryState => {
|
||||
if (activeTabID === overTabID) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const fromIndex = current.tabs.findIndex((tab) => tab.id === activeTabID);
|
||||
const toIndex = current.tabs.findIndex((tab) => tab.id === overTabID);
|
||||
if (fromIndex === -1 || toIndex === -1) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const tabs = [...current.tabs];
|
||||
const [moved] = tabs.splice(fromIndex, 1);
|
||||
if (!moved) {
|
||||
return current;
|
||||
}
|
||||
|
||||
tabs.splice(toIndex, 0, moved);
|
||||
|
||||
return {
|
||||
...current,
|
||||
tabs,
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeContextPanelByDirectory = (
|
||||
value: unknown,
|
||||
): Record<string, ContextPanelDirectoryState> => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>;
|
||||
const next: Record<string, ContextPanelDirectoryState> = {};
|
||||
|
||||
for (const [rawDirectory, rawState] of Object.entries(source)) {
|
||||
const directory = normalizeDirectoryPath(rawDirectory);
|
||||
if (!directory || !rawState || typeof rawState !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = rawState as {
|
||||
isOpen?: unknown;
|
||||
expanded?: unknown;
|
||||
tabs?: unknown;
|
||||
activeTabId?: unknown;
|
||||
width?: unknown;
|
||||
touchedAt?: unknown;
|
||||
mode?: unknown;
|
||||
targetPath?: unknown;
|
||||
dedupeKey?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
|
||||
let tabs = sanitizeContextPanelTabs(candidate.tabs);
|
||||
let activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null;
|
||||
|
||||
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat')) {
|
||||
tabs = [createContextPanelTab({
|
||||
mode: candidate.mode,
|
||||
targetPath: typeof candidate.targetPath === 'string' ? candidate.targetPath : null,
|
||||
dedupeKey: typeof candidate.dedupeKey === 'string' ? candidate.dedupeKey : null,
|
||||
label: typeof candidate.label === 'string' ? candidate.label : null,
|
||||
})];
|
||||
activeTabId = tabs[0]?.id ?? null;
|
||||
}
|
||||
|
||||
const resolvedActiveTabId = resolveActiveContextPanelTabID(tabs, activeTabId);
|
||||
const clampedTabs = clampContextPanelTabs(tabs, CONTEXT_PANEL_MAX_TABS, resolvedActiveTabId);
|
||||
|
||||
next[directory] = {
|
||||
isOpen: candidate.isOpen === true,
|
||||
expanded: candidate.expanded === true,
|
||||
tabs: clampedTabs,
|
||||
activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId),
|
||||
width: clampContextPanelWidth(typeof candidate.width === 'number' ? candidate.width : CONTEXT_PANEL_DEFAULT_WIDTH),
|
||||
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
||||
? candidate.touchedAt
|
||||
: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const clampContextPanelRoots = (
|
||||
byDirectory: Record<string, ContextPanelDirectoryState>,
|
||||
maxRoots: number
|
||||
@@ -152,6 +475,7 @@ interface UIStore {
|
||||
mainTabGuard: MainTabGuard | null;
|
||||
sidebarOpenBeforeFullscreenTab: boolean | null;
|
||||
pendingDiffFile: string | null;
|
||||
pendingFileNavigation: PendingFileNavigation | null;
|
||||
isMobile: boolean;
|
||||
isKeyboardOpen: boolean;
|
||||
isCommandPaletteOpen: boolean;
|
||||
@@ -188,6 +512,7 @@ interface UIStore {
|
||||
|
||||
favoriteModels: Array<{ providerID: string; modelID: string }>;
|
||||
hiddenModels: Array<{ providerID: string; modelID: string }>;
|
||||
collapsedModelProviders: string[];
|
||||
recentModels: Array<{ providerID: string; modelID: string }>;
|
||||
recentAgents: string[];
|
||||
recentEfforts: Record<string, string[]>;
|
||||
@@ -224,6 +549,8 @@ interface UIStore {
|
||||
showTerminalQuickKeysOnDesktop: boolean;
|
||||
persistChatDraft: boolean;
|
||||
mermaidRenderingMode: MermaidRenderingMode;
|
||||
userMessageRenderingMode: UserMessageRenderingMode;
|
||||
stickyUserHeader: boolean;
|
||||
showMobileSessionStatusBar: boolean;
|
||||
isMobileSessionStatusBarCollapsed: boolean;
|
||||
viewPagerPage: 'left' | 'center' | 'right';
|
||||
@@ -240,10 +567,15 @@ interface UIStore {
|
||||
setRightSidebarOpen: (open: boolean) => void;
|
||||
setRightSidebarWidth: (width: number) => void;
|
||||
setRightSidebarTab: (tab: RightSidebarTab) => void;
|
||||
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
|
||||
openContextDiff: (directory: string, filePath: string) => void;
|
||||
openContextFile: (directory: string, filePath: string) => void;
|
||||
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
|
||||
openContextOverview: (directory: string) => void;
|
||||
openContextPlan: (directory: string) => void;
|
||||
setActiveContextPanelTab: (directory: string, tabID: string) => void;
|
||||
reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void;
|
||||
closeContextPanelTab: (directory: string, tabID: string) => void;
|
||||
closeContextPanel: (directory: string) => void;
|
||||
toggleContextPanelExpanded: (directory: string) => void;
|
||||
setContextPanelWidth: (directory: string, width: number) => void;
|
||||
@@ -257,6 +589,7 @@ interface UIStore {
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
||||
setPendingDiffFile: (filePath: string | null) => void;
|
||||
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
|
||||
navigateToDiff: (filePath: string) => void;
|
||||
consumePendingDiffFile: () => string | null;
|
||||
setIsMobile: (isMobile: boolean) => void;
|
||||
@@ -298,6 +631,7 @@ interface UIStore {
|
||||
isHiddenModel: (providerID: string, modelID: string) => boolean;
|
||||
hideAllModels: (providerID: string, modelIDs: string[]) => void;
|
||||
showAllModels: (providerID: string) => void;
|
||||
toggleModelProviderCollapsed: (providerID: string) => void;
|
||||
isFavoriteModel: (providerID: string, modelID: string) => boolean;
|
||||
addRecentModel: (providerID: string, modelID: string) => void;
|
||||
addRecentAgent: (agentName: string) => void;
|
||||
@@ -323,6 +657,8 @@ interface UIStore {
|
||||
setMaxLastMessageLength: (value: number) => void;
|
||||
setPersistChatDraft: (value: boolean) => void;
|
||||
setMermaidRenderingMode: (value: MermaidRenderingMode) => void;
|
||||
setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void;
|
||||
setStickyUserHeader: (value: boolean) => void;
|
||||
setShowMobileSessionStatusBar: (value: boolean) => void;
|
||||
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
||||
setViewPagerPage: (page: 'left' | 'center' | 'right') => void;
|
||||
@@ -362,6 +698,7 @@ export const useUIStore = create<UIStore>()(
|
||||
mainTabGuard: null,
|
||||
sidebarOpenBeforeFullscreenTab: null,
|
||||
pendingDiffFile: null,
|
||||
pendingFileNavigation: null,
|
||||
isMobile: false,
|
||||
isKeyboardOpen: false,
|
||||
isCommandPaletteOpen: false,
|
||||
@@ -394,6 +731,7 @@ export const useUIStore = create<UIStore>()(
|
||||
inputBarOffset: 0,
|
||||
favoriteModels: [],
|
||||
hiddenModels: [],
|
||||
collapsedModelProviders: [],
|
||||
recentModels: [],
|
||||
recentAgents: [],
|
||||
recentEfforts: {},
|
||||
@@ -427,6 +765,8 @@ export const useUIStore = create<UIStore>()(
|
||||
showTerminalQuickKeysOnDesktop: false,
|
||||
persistChatDraft: true,
|
||||
mermaidRenderingMode: 'svg',
|
||||
userMessageRenderingMode: 'markdown',
|
||||
stickyUserHeader: true,
|
||||
showMobileSessionStatusBar: true,
|
||||
isMobileSessionStatusBarCollapsed: false,
|
||||
isExpandedInput: false,
|
||||
@@ -525,10 +865,9 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ rightSidebarTab: tab });
|
||||
},
|
||||
|
||||
openContextDiff: (directory, filePath) => {
|
||||
openContextPanelTab: (directory, tab) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedFilePath = (filePath || '').trim();
|
||||
if (!normalizedDirectory || !normalizedFilePath) {
|
||||
if (!normalizedDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -537,16 +876,21 @@ export const useUIStore = create<UIStore>()(
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'diff' as const,
|
||||
targetPath: normalizedFilePath,
|
||||
},
|
||||
[normalizedDirectory]: upsertContextPanelTab(current, tab),
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
openContextDiff: (directory, filePath) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedFilePath = (filePath || '').trim();
|
||||
if (!normalizedDirectory || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'diff', targetPath: normalizedFilePath });
|
||||
get().setPendingDiffFile(normalizedFilePath);
|
||||
},
|
||||
|
||||
@@ -557,20 +901,24 @@ export const useUIStore = create<UIStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'file' as const,
|
||||
targetPath: normalizedFilePath,
|
||||
},
|
||||
};
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'file', targetPath: normalizedFilePath });
|
||||
get().setPendingFileNavigation(null);
|
||||
},
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
openContextFileAtLine: (directory, filePath, line, column) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedFilePath = normalizeContextTargetPath(filePath);
|
||||
const normalizedLine = Number.isFinite(line) ? Math.max(1, Math.trunc(line)) : 1;
|
||||
const normalizedColumn = Number.isFinite(column) ? Math.max(1, Math.trunc(column as number)) : 1;
|
||||
if (!normalizedDirectory || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'file', targetPath: normalizedFilePath });
|
||||
get().setPendingFileNavigation({
|
||||
path: normalizedFilePath,
|
||||
line: normalizedLine,
|
||||
column: normalizedColumn,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -580,21 +928,7 @@ export const useUIStore = create<UIStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'context' as const,
|
||||
targetPath: null,
|
||||
},
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'context' });
|
||||
},
|
||||
|
||||
openContextPlan: (directory) => {
|
||||
@@ -603,16 +937,37 @@ export const useUIStore = create<UIStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'plan' });
|
||||
},
|
||||
|
||||
setActiveContextPanelTab: (directory, tabID) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedTabID = (tabID || '').trim();
|
||||
if (!normalizedDirectory || !normalizedTabID) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
if (!current.tabs.some((tab) => tab.id === normalizedTabID)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (current.activeTabId === normalizedTabID && current.isOpen) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'plan' as const,
|
||||
targetPath: null,
|
||||
activeTabId: normalizedTabID,
|
||||
touchedAt: Date.now(),
|
||||
tabs: current.tabs.map((tab) => (tab.id === normalizedTabID
|
||||
? { ...tab, touchedAt: Date.now() }
|
||||
: tab)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -620,6 +975,58 @@ export const useUIStore = create<UIStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
reorderContextPanelTabs: (directory, activeTabID, overTabID) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedActiveTabID = (activeTabID || '').trim();
|
||||
const normalizedOverTabID = (overTabID || '').trim();
|
||||
if (!normalizedDirectory || !normalizedActiveTabID || !normalizedOverTabID) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
if (!current.tabs.some((tab) => tab.id === normalizedActiveTabID) || !current.tabs.some((tab) => tab.id === normalizedOverTabID)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const next = reorderContextPanelTabs(current, normalizedActiveTabID, normalizedOverTabID);
|
||||
if (next.tabs === current.tabs) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: next,
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
closeContextPanelTab: (directory, tabID) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedTabID = (tabID || '').trim();
|
||||
if (!normalizedDirectory || !normalizedTabID) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
if (!current.tabs.some((tab) => tab.id === normalizedTabID)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: closeContextPanelTab(current, normalizedTabID),
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
closeContextPanel: (directory) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
if (!normalizedDirectory) {
|
||||
@@ -774,6 +1181,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ pendingDiffFile: filePath });
|
||||
},
|
||||
|
||||
setPendingFileNavigation: (navigation) => {
|
||||
set({ pendingFileNavigation: navigation });
|
||||
},
|
||||
|
||||
navigateToDiff: (filePath) => {
|
||||
const guard = get().mainTabGuard;
|
||||
if (guard && !guard('diff')) {
|
||||
@@ -1074,6 +1485,26 @@ export const useUIStore = create<UIStore>()(
|
||||
}));
|
||||
},
|
||||
|
||||
toggleModelProviderCollapsed: (providerID) => {
|
||||
const normalizedProviderID = typeof providerID === 'string' ? providerID.trim() : '';
|
||||
if (!normalizedProviderID) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const isCollapsed = state.collapsedModelProviders.includes(normalizedProviderID);
|
||||
if (isCollapsed) {
|
||||
return {
|
||||
collapsedModelProviders: state.collapsedModelProviders.filter((id) => id !== normalizedProviderID),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
collapsedModelProviders: [...state.collapsedModelProviders, normalizedProviderID],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
isFavoriteModel: (providerID, modelID) => {
|
||||
const { favoriteModels } = get();
|
||||
return favoriteModels.some(
|
||||
@@ -1224,6 +1655,12 @@ export const useUIStore = create<UIStore>()(
|
||||
setMermaidRenderingMode: (value) => {
|
||||
set({ mermaidRenderingMode: value });
|
||||
},
|
||||
setUserMessageRenderingMode: (value) => {
|
||||
set({ userMessageRenderingMode: value });
|
||||
},
|
||||
setStickyUserHeader: (value) => {
|
||||
set({ stickyUserHeader: value });
|
||||
},
|
||||
setShowMobileSessionStatusBar: (value) => {
|
||||
set({ showMobileSessionStatusBar: value });
|
||||
},
|
||||
@@ -1274,7 +1711,7 @@ export const useUIStore = create<UIStore>()(
|
||||
{
|
||||
name: 'ui-store',
|
||||
storage: createJSONStorage(() => getSafeStorage()),
|
||||
version: 5,
|
||||
version: 7,
|
||||
migrate: (persistedState, version) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState;
|
||||
@@ -1318,9 +1755,7 @@ export const useUIStore = create<UIStore>()(
|
||||
state.rightSidebarTab = 'git';
|
||||
}
|
||||
|
||||
if (!state.contextPanelByDirectory || typeof state.contextPanelByDirectory !== 'object') {
|
||||
state.contextPanelByDirectory = {};
|
||||
}
|
||||
state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory);
|
||||
|
||||
if (version < 5) {
|
||||
if (!state.shortcutOverrides || typeof state.shortcutOverrides !== 'object') {
|
||||
@@ -1337,6 +1772,14 @@ export const useUIStore = create<UIStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 6) {
|
||||
state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory);
|
||||
}
|
||||
|
||||
if (version < 7) {
|
||||
state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory);
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
@@ -1374,6 +1817,7 @@ export const useUIStore = create<UIStore>()(
|
||||
cornerRadius: state.cornerRadius,
|
||||
favoriteModels: state.favoriteModels,
|
||||
hiddenModels: state.hiddenModels,
|
||||
collapsedModelProviders: state.collapsedModelProviders,
|
||||
recentModels: state.recentModels,
|
||||
recentAgents: state.recentAgents,
|
||||
recentEfforts: state.recentEfforts,
|
||||
@@ -1394,6 +1838,8 @@ export const useUIStore = create<UIStore>()(
|
||||
maxLastMessageLength: state.maxLastMessageLength,
|
||||
persistChatDraft: state.persistChatDraft,
|
||||
mermaidRenderingMode: state.mermaidRenderingMode,
|
||||
userMessageRenderingMode: state.userMessageRenderingMode,
|
||||
stickyUserHeader: state.stickyUserHeader,
|
||||
showMobileSessionStatusBar: state.showMobileSessionStatusBar,
|
||||
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
|
||||
shortcutOverrides: state.shortcutOverrides,
|
||||
|
||||
Reference in New Issue
Block a user