feat: redesign settings pages to match canonical flat UI patterns (#493)

* refactor(settings): new IA shell + projects section + skills catalog discoverability

* chore(settings): split providers list by scope; show user before project

* fix: navigation flow in mobile Settings

* feat: redesign settings pages to use modern elevated surface patterns

* feat: replace helper text with tooltips in settings

* ui: redesign update dialog and fix external link routing

- Restructures UpdateDialog to focus on changelog readability with a wider max-w-4xl canvas
- Highlights @username contributor mentions with theme primary color
- Strips excessive vertical padding and right-aligns compact action buttons
- Disables streamdown's internal link safety dialog in favor of direct Tauri shell routing

* feat: refactor Git identities into dedicated Git settings page

* feat: unify sidebar background styling across VS Code and web/mobile

* fix: adjust button styling and layout for mobile settings pages

* feat: add MCP settings page and sidebar

* feat: hide models in provider view (thanks to @nguyenngothuong)

* feat: add "Add new provider" option to model selector dropdown

* fix: local evroc logo + provider dropdown icons

* fix: increase width of provider menu

* fix: dark theme background color for better contrast

* feat: update @opencode-ai/sdk dependency to v1.2.10

* fix: restore session sorting to only use updated time

* fix: added settings for sessions deletion dialog

* fix: adjust padding on settings pages for better layout

* fix: standardize select dropdown height across UI

* fix: agent selector UI and notification settings

* fix: remove redundant helper text from settings pages

* fix: update UI layout for description fields

* fix: remove border-none and shadow-none from textarea classes

* fix: enable context menu on sidebar items

* feat: refactor UI controls and layout patterns across settings pages

* fix: use headerless blocks when page title already provides context

* fix: remove subtask option from command settings

* fix: refactor mcp page settings

* fix: reduce spacing in skills configuration pages

* feat: refactor voice settings

* feat: refactor settings sidebar sections
This commit is contained in:
Bohdan Triapitsyn
2026-02-24 03:28:30 +02:00
committed by GitHub
parent d2d39c48ac
commit d2358c2c03
90 changed files with 8062 additions and 7128 deletions
@@ -74,7 +74,7 @@ export function GitHubIssuePickerDialog({
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
@@ -181,9 +181,9 @@ export function GitHubIssuePickerDialog({
const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => {
setSidebarSection('settings');
setSettingsPage('github');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]);
}, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
@@ -110,7 +110,7 @@ export function GitHubPullRequestPickerDialog({
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
@@ -300,9 +300,9 @@ export function GitHubPullRequestPickerDialog({
const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => {
setSidebarSection('settings');
setSettingsPage('github');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]);
}, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
@@ -21,6 +21,7 @@ import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { isDesktopLocalOriginActive, isTauriShell } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
@@ -64,6 +65,8 @@ export const SessionDialogs: React.FC = () => {
loadSessions,
getWorktreeMetadata,
} = useSessionStore();
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
const { projects, addProject, activeProjectId } = useProjectsStore();
const { requestAccess, startAccessing } = useFileSystemAccess();
@@ -194,11 +197,52 @@ export const SessionDialogs: React.FC = () => {
setDirtyWorktreePaths(new Set());
}, []);
const deleteSessionsWithoutDialog = React.useCallback(async (payload: { sessions: Session[]; dateLabel?: string }) => {
if (payload.sessions.length === 0) {
return;
}
if (payload.sessions.length === 1) {
const target = payload.sessions[0];
const success = await deleteSession(target.id);
if (success) {
toast.success('Session deleted');
} else {
toast.error('Failed to delete session');
}
return;
}
const ids = payload.sessions.map((session) => session.id);
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
const successDescription = failedIds.length > 0
? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.`
: payload.dateLabel
? `Removed all sessions from ${payload.dateLabel}.`
: undefined;
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription(successDescription),
});
}
if (failedIds.length > 0) {
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription('Please try again in a moment.'),
});
}
}, [deleteSession, deleteSessions]);
React.useEffect(() => {
return sessionEvents.onDeleteRequest((payload) => {
if (!showDeletionDialog && (payload.mode ?? 'session') === 'session') {
void deleteSessionsWithoutDialog(payload);
return;
}
openDeleteDialog(payload);
});
}, [openDeleteDialog]);
}, [openDeleteDialog, showDeletionDialog, deleteSessionsWithoutDialog]);
React.useEffect(() => {
return sessionEvents.onDirectoryRequest(() => {
@@ -634,18 +678,29 @@ export const SessionDialogs: React.FC = () => {
</div>
</div>
) : (
<>
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
{isProcessingDelete
? 'Deleting…'
: deleteDialog?.sessions.length === 1
? 'Delete session'
: 'Delete sessions'}
</Button>
</>
<div className="flex w-full items-center justify-between gap-3">
<button
type="button"
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
className="inline-flex items-center gap-1.5 typography-meta text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
aria-pressed={!showDeletionDialog}
>
{!showDeletionDialog ? <RiCheckboxLine className="size-4 text-primary" /> : <RiCheckboxBlankLine className="size-4" />}
Never ask
</button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
{isProcessingDelete
? 'Deleting…'
: deleteDialog?.sessions.length === 1
? 'Delete session'
: 'Delete sessions'}
</Button>
</div>
</div>
);
const deleteDialogTitle = isWorktreeDelete
@@ -51,6 +51,8 @@ import {
RiAddLine,
RiArrowDownSLine,
RiArrowRightSLine,
RiCheckboxBlankLine,
RiCheckboxLine,
RiCheckLine,
RiCloseLine,
RiDeleteBinLine,
@@ -194,25 +196,14 @@ const getSessionCreatedAt = (session: Session): number => {
return toFiniteNumber(session.time?.created) ?? 0;
};
const getSessionUpdatedAt = (session: Session, attentionStates?: Map<string, { lastUserMessageAt: number | null; lastStatusChangeAt: number }>): number => {
const baseUpdated = toFiniteNumber(session.time?.updated) ?? 0;
if (!attentionStates) return baseUpdated;
const attention = attentionStates.get(session.id);
if (!attention) return baseUpdated;
return Math.max(
baseUpdated,
attention.lastUserMessageAt ?? 0,
attention.lastStatusChangeAt ?? 0
);
const getSessionUpdatedAt = (session: Session): number => {
return toFiniteNumber(session.time?.updated) ?? toFiniteNumber(session.time?.created) ?? 0;
};
const compareSessionsByPinnedAndTime = (
a: Session,
b: Session,
pinnedSessionIds: Set<string>,
attentionStates?: Map<string, { lastUserMessageAt: number | null; lastStatusChangeAt: number }>
pinnedSessionIds: Set<string>
): number => {
const aPinned = pinnedSessionIds.has(a.id);
const bPinned = pinnedSessionIds.has(b.id);
@@ -224,7 +215,7 @@ const compareSessionsByPinnedAndTime = (
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
}
return getSessionUpdatedAt(b, attentionStates) - getSessionUpdatedAt(a, attentionStates);
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
};
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
@@ -883,6 +874,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
// Session Folders store
@@ -1026,8 +1019,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, []);
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates));
}, [sessions, pinnedSessionIds, sessionAttentionStates]);
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
React.useEffect(() => {
let cancelled = false;
@@ -1081,9 +1074,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
collection.push(session);
map.set(parentID, collection);
});
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates)));
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
return map;
}, [sortedSessions, pinnedSessionIds, sessionAttentionStates]);
}, [sortedSessions, pinnedSessionIds]);
React.useEffect(() => {
const directories = new Set<string>();
@@ -1314,27 +1307,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const deleteSession = useSessionStore((state) => state.deleteSession);
const deleteSessions = useSessionStore((state) => state.deleteSessions);
const handleDeleteSession = React.useCallback(
(session: Session) => {
const executeDeleteSession = React.useCallback(
async (session: Session) => {
const descendants = collectDescendants(session.id);
setDeleteSessionConfirm({ session, descendantCount: descendants.length });
},
[collectDescendants],
);
const confirmDeleteSession = React.useCallback(async () => {
if (!deleteSessionConfirm) return;
const { session } = deleteSessionConfirm;
setDeleteSessionConfirm(null);
const descendants = collectDescendants(session.id);
if (descendants.length === 0) {
const success = await deleteSession(session.id);
if (success) {
toast.success('Session deleted');
} else {
toast.error('Failed to delete session');
if (descendants.length === 0) {
const success = await deleteSession(session.id);
if (success) {
toast.success('Session deleted');
} else {
toast.error('Failed to delete session');
}
return;
}
} else {
const ids = [session.id, ...descendants.map((s) => s.id)];
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
@@ -1343,8 +1328,28 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (failedIds.length > 0) {
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
}
}
}, [deleteSessionConfirm, collectDescendants, deleteSession, deleteSessions]);
},
[collectDescendants, deleteSession, deleteSessions],
);
const handleDeleteSession = React.useCallback(
(session: Session) => {
const descendants = collectDescendants(session.id);
if (!showDeletionDialog) {
void executeDeleteSession(session);
return;
}
setDeleteSessionConfirm({ session, descendantCount: descendants.length });
},
[collectDescendants, showDeletionDialog, executeDeleteSession],
);
const confirmDeleteSession = React.useCallback(async () => {
if (!deleteSessionConfirm) return;
const { session } = deleteSessionConfirm;
setDeleteSessionConfirm(null);
await executeDeleteSession(session);
}, [deleteSessionConfirm, executeDeleteSession]);
const confirmDeleteFolder = React.useCallback(() => {
if (!deleteFolderConfirm) return;
@@ -1396,6 +1401,24 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
}, [safeStorage]);
const createFolderAndStartRename = React.useCallback(
(scopeKey: string, parentId?: string | null) => {
if (!scopeKey) {
return null;
}
if (parentId && collapsedFolderIds.has(parentId)) {
toggleFolderCollapse(parentId);
}
const newFolder = createFolder(scopeKey, 'New folder', parentId);
setRenamingFolderId(newFolder.id);
setRenameFolderDraft(newFolder.name);
return newFolder;
},
[collapsedFolderIds, toggleFolderCollapse, createFolder],
);
const buildNode = React.useCallback(
(session: Session): SessionNode => {
const children = childrenMap.get(session.id) ?? [];
@@ -1418,7 +1441,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
projectIsRepo: boolean,
) => {
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates));
const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
const childrenMap = new Map<string, Session[]>();
@@ -1431,7 +1454,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
collection.push(session);
childrenMap.set(parentID, collection);
});
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds, sessionAttentionStates)));
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
// Build worktree lookup map
const worktreeByPath = new Map<string, WorktreeMetadata>();
@@ -1564,7 +1587,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return groups;
},
[homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories, sessionAttentionStates]
[homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories]
);
const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
@@ -2406,9 +2429,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
const newFolder = createFolder(sessionDirectory, 'New folder');
addSessionToFolder(sessionDirectory, newFolder.id, session.id);
}}
const newFolder = createFolderAndStartRename(sessionDirectory);
if (!newFolder) {
return;
}
addSessionToFolder(sessionDirectory, newFolder.id, session.id);
}}
>
<RiAddLine className="mr-1 h-4 w-4" />
New folder...
@@ -2480,7 +2506,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
getSessionFolderId,
addSessionToFolder,
removeSessionFromFolder,
createFolder,
createFolderAndStartRename,
notifyOnSubtasks,
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
],
@@ -2504,7 +2530,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const nodes = folder.sessionIds
.map((sid) => group.sessions.find((node) => node.session.id === sid))
.filter((n): n is SessionNode => Boolean(n))
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds, sessionAttentionStates));
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds));
return { folder, nodes };
});
@@ -2561,6 +2587,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}}
onDelete={() => {
if (!folderScopeKey) return;
if (!showDeletionDialog) {
deleteFolder(folderScopeKey, folder.id);
return;
}
// Count affected sub-folders and sessions for the confirm dialog
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
const sessionCount = nodes.length;
@@ -2604,10 +2634,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id });
}}
onNewSubFolder={depth === 0 ? () => {
if (!folderScopeKey) return;
createFolder(folderScopeKey, 'New folder', folder.id);
} : undefined}
onNewSubFolder={depth === 0 ? () => {
if (!folderScopeKey) return;
createFolderAndStartRename(folderScopeKey, folder.id);
} : undefined}
/>
)}
</DroppableFolderWrapper>
@@ -2750,27 +2780,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</TooltipContent>
</Tooltip>
) : null}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`New session or folder in ${group.label}`}
>
<RiAddLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>New session or folder</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem
onClick={() => {
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
if (projectId && projectId !== activeProjectId) {
setActiveProject(projectId);
}
@@ -2780,22 +2795,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
openNewSessionDraft({ directoryOverride: group.directory });
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={`New session in ${group.label}`}
>
<RiAddLine className="mr-1.5 h-4 w-4" />
New session
</DropdownMenuItem>
{folderScopeKey ? (
<DropdownMenuItem
onClick={() => {
createFolder(folderScopeKey, 'New folder');
}}
>
<RiFolderAddLine className="mr-1.5 h-4 w-4" />
New folder
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<RiAddLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>New session</p>
</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
@@ -2856,14 +2865,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
getFoldersForScope,
collapsedFolderIds,
toggleFolderCollapse,
createFolder,
createFolderAndStartRename,
renameFolder,
deleteFolder,
showDeletionDialog,
addSessionToFolder,
renamingFolderId,
renameFolderDraft,
pinnedSessionIds,
sessionAttentionStates,
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
]
);
@@ -3396,21 +3405,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
: `"${deleteSessionConfirm?.session.title || 'Untitled Session'}" will be permanently deleted.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogFooter className="w-full sm:items-center sm:justify-between">
<button
type="button"
onClick={() => setDeleteSessionConfirm(null)}
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
className="inline-flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
aria-pressed={!showDeletionDialog}
>
Cancel
</button>
<button
type="button"
onClick={() => void confirmDeleteSession()}
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
Delete
{!showDeletionDialog ? <RiCheckboxLine className="h-4 w-4 text-primary" /> : <RiCheckboxBlankLine className="h-4 w-4" />}
Never ask
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setDeleteSessionConfirm(null)}
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Cancel
</button>
<button
type="button"
onClick={() => void confirmDeleteSession()}
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
>
Delete
</button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>