feat(ui): redesign workspace shell with context panel, tabbed sidebars, and faster diff UX (#433)
* feat: tabbed right sidebar, context panel, floating diff comments * fix: auto-close left sidebar when context panel opens - Increase default context panel width from 520 to 600 pixels - Increase sidebar minimum width from 200 to 300 pixels - Replace collapsible component with custom button in diff view * refactoring: rework sidebars, tabs, and file tree layout - Rewrite AnimatedTabs as segment-style with sliding indicator - Upgrade SidebarFilesTree to match FilesView features (context menus, git status, file icons, CRUD dialogs, fuzzy search ranking) - Restructure FilesView header: tabs row + actions row, remove breadcrumbs - Show relative path in context panel header, track active tab - Allow left sidebar to stay open alongside context panel - Hide diff/files tabs from header on desktop (mobile-only) - Move chevron after group name in session sidebar - Compact tab heights in right sidebar and git view - Size PreviewToggleButton to match other action buttons - Remove directory loading spinner from folder icons * feat: add project icon and color customization - Enable users to assign custom icons to projects - Allow users to choose accent colors for projects - Stabilize repo status UI during project switching * feat: add scroll fade indicators to editor tabs * style: reduce spacing and icon sizes in header * style: adjust tab component padding from uniform to vertical-horizontal * feat: Add session state indicators to project tabs * feat: Enhance session status handling and improve UI responsiveness * fix: preserve upstream tracking on branch rename * fix: improve initial remote selection for pull requests - Uses saved remote name from previous session when available - Selects remote based on tracking branch when possible - Falls back to origin or first available remote * perf(diff): faster highlight, stable stacked scroll - split/unified Pierre worker pools; prefer shiki-wasm - align diff CSS line-height; disable scroll anchoring; drop WebKit compositing hacks - harden stacked pin/align (cancel on user scroll/input); prevent overscroll - make overlay scrollbar MutationObserver optional; disable for diff container * feat: handle binary files in diff view * fix: adjust project tabs layout and drag regions * style: update drag overlay visual styling * feat: enable number keys to switch projects in the sidebar * fix: recognize octet-stream as text-based MIME type * feat: add keyboard navigation to context panel * feat: add session pinning to sidebar - Pin important sessions to keep them at the top - Pinned sessions persist across browser sessions * refactor: move context usage display from chat input to header
This commit is contained in:
committed by
GitHub
parent
12606b9e53
commit
47c943b487
@@ -17,10 +17,17 @@ import {
|
||||
RiLoader4Line,
|
||||
RiPencilLine,
|
||||
RiSearchLine,
|
||||
RiSplitCellsHorizontal,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi';
|
||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
export interface BranchPickerProject {
|
||||
id: string;
|
||||
@@ -38,13 +45,35 @@ interface BranchPickerDialogProps {
|
||||
const displayProjectName = (project: BranchPickerProject): string =>
|
||||
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
|
||||
|
||||
const normalizeBranchName = (value: string | null | undefined): string => {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/^remotes\//, '');
|
||||
};
|
||||
|
||||
const normalizePath = (value: string | null | undefined): string => {
|
||||
const raw = String(value || '').trim().replace(/\\/g, '/');
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (raw === '/') {
|
||||
return '/';
|
||||
}
|
||||
return raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
|
||||
};
|
||||
|
||||
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [branches, setBranches] = React.useState<GitBranch | null>(null);
|
||||
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
|
||||
const [rootBranchName, setRootBranchName] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [creatingWorktreeBranch, setCreatingWorktreeBranch] = React.useState<string | null>(null);
|
||||
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
|
||||
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
|
||||
@@ -57,16 +86,19 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [b, w] = await Promise.all([
|
||||
const [b, w, rootBranch] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
git.worktree.list(project.path),
|
||||
getRootBranch(project.path).catch(() => null),
|
||||
]);
|
||||
setBranches(b);
|
||||
setWorktrees(w);
|
||||
setRootBranchName(rootBranch);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setRootBranchName(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -80,6 +112,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
setCreatingWorktreeBranch(null);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
@@ -161,7 +194,105 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
}
|
||||
}, [project, refresh, forceDeleteBranch]);
|
||||
|
||||
const worktreeBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean));
|
||||
const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingWorktreeBranch(branchName);
|
||||
try {
|
||||
const setupCommands = await getWorktreeSetupCommands({
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
});
|
||||
await createWorktreeWithDefaults(
|
||||
{
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
},
|
||||
{
|
||||
preferredName: branchName,
|
||||
mode: 'existing',
|
||||
existingBranch: branchName,
|
||||
branchName,
|
||||
worktreeName: branchName,
|
||||
setupCommands,
|
||||
}
|
||||
);
|
||||
await refresh();
|
||||
toast.success('Worktree created', { description: branchName });
|
||||
} catch (err) {
|
||||
toast.error('Failed to create worktree', {
|
||||
description: err instanceof Error ? err.message : 'Create worktree failed',
|
||||
});
|
||||
} finally {
|
||||
setCreatingWorktreeBranch(null);
|
||||
}
|
||||
}, [project, refresh]);
|
||||
|
||||
const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => {
|
||||
if (!project || !worktree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedWorktreePath = normalizePath(worktree.path);
|
||||
const directSessions = sessions.filter((session) => {
|
||||
const sessionPath = normalizePath(session.directory ?? null);
|
||||
return Boolean(sessionPath) && sessionPath === normalizedWorktreePath;
|
||||
});
|
||||
const directSessionIds = new Set(directSessions.map((session) => session.id));
|
||||
|
||||
const findSubsessions = (parentIds: Set<string>): typeof sessions => {
|
||||
const subsessions = sessions.filter((session) => {
|
||||
const parentID = (session as { parentID?: string | null }).parentID;
|
||||
if (!parentID) {
|
||||
return false;
|
||||
}
|
||||
return parentIds.has(parentID);
|
||||
});
|
||||
if (subsessions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const subsessionIds = new Set(subsessions.map((session) => session.id));
|
||||
return [...subsessions, ...findSubsessions(subsessionIds)];
|
||||
};
|
||||
|
||||
const allSubsessions = findSubsessions(directSessionIds);
|
||||
const seenIds = new Set<string>();
|
||||
const allSessions = [...directSessions, ...allSubsessions].filter((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(session.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const normalizedBranch = normalizeBranchName(worktree.branch);
|
||||
const worktreeMetadata: WorktreeMetadata = {
|
||||
source: 'sdk',
|
||||
name: worktree.name,
|
||||
path: worktree.path,
|
||||
projectDirectory: project.path,
|
||||
branch: normalizedBranch,
|
||||
label: normalizedBranch || worktree.name,
|
||||
};
|
||||
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allSessions,
|
||||
mode: 'worktree',
|
||||
worktree: worktreeMetadata,
|
||||
});
|
||||
}, [project, sessions]);
|
||||
|
||||
const worktreeByBranch = new Map<string, GitWorktreeInfo>();
|
||||
for (const worktree of worktrees) {
|
||||
const branchName = normalizeBranchName(worktree.branch);
|
||||
if (branchName && !worktreeByBranch.has(branchName)) {
|
||||
worktreeByBranch.set(branchName, worktree);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRootBranch = normalizeBranchName(rootBranchName);
|
||||
const allBranches = branches?.all || [];
|
||||
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
|
||||
@@ -204,16 +335,34 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
) : (
|
||||
localBranches.map((branchName) => {
|
||||
const details = branches?.branches[branchName];
|
||||
const normalizedBranchName = normalizeBranchName(branchName);
|
||||
const isCurrent = Boolean(details?.current);
|
||||
const isDeleting = deletingBranch === branchName;
|
||||
const isRenaming = renamingBranchKey === branchName;
|
||||
const hasAttachedWorktree = worktreeBranches.has(branchName);
|
||||
const attachedWorktree = worktreeByBranch.get(normalizedBranchName) ?? null;
|
||||
const hasAttachedWorktree = Boolean(attachedWorktree);
|
||||
const isProjectRootBranch = Boolean(
|
||||
normalizedBranchName &&
|
||||
normalizedRootBranch &&
|
||||
normalizedBranchName === normalizedRootBranch
|
||||
);
|
||||
const isEditing = editingBranch === branchName;
|
||||
const isConfirming = confirmingDelete === branchName;
|
||||
const isForceDelete = forceDeleteBranch === branchName;
|
||||
const isCreatingWorktree = creatingWorktreeBranch === branchName;
|
||||
|
||||
const disableDelete = Boolean(isCurrent || hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
const disableRename = Boolean(hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
const disableCreateWorktree = Boolean(
|
||||
hasAttachedWorktree || isCreatingWorktree || isDeleting || isRenaming || isEditing
|
||||
);
|
||||
const disableDelete = Boolean(
|
||||
isCurrent || isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableRename = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableWorktreeDelete = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch || !attachedWorktree
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -258,7 +407,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
current
|
||||
HEAD
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -284,6 +433,27 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
|
||||
{!isEditing && !isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateWorktreeForBranch(branchName)}
|
||||
disabled={disableCreateWorktree}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label="Create worktree"
|
||||
>
|
||||
{isCreatingWorktree ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiSplitCellsHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? 'Worktree already exists' : 'Create worktree'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -297,7 +467,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? 'Rename (remove worktree first)' : 'Rename'}
|
||||
{isProjectRootBranch ? 'Rename disabled for root branch' : 'Rename'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -305,10 +475,16 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(branchName)}
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
if (hasAttachedWorktree) {
|
||||
handleRemoveWorktree(attachedWorktree);
|
||||
return;
|
||||
}
|
||||
setConfirmingDelete(branchName);
|
||||
}}
|
||||
disabled={hasAttachedWorktree ? disableWorktreeDelete : disableDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label="Delete"
|
||||
aria-label={hasAttachedWorktree ? 'Delete worktree' : 'Delete'}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
@@ -318,11 +494,15 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{isCurrent
|
||||
? 'Delete (current branch)'
|
||||
: hasAttachedWorktree
|
||||
? 'Delete (remove worktree first)'
|
||||
: 'Delete'}
|
||||
{hasAttachedWorktree
|
||||
? isProjectRootBranch
|
||||
? 'Delete worktree (root branch protected)'
|
||||
: 'Delete worktree'
|
||||
: isCurrent
|
||||
? 'Delete (current branch)'
|
||||
: isProjectRootBranch
|
||||
? 'Delete disabled for root branch'
|
||||
: 'Delete'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -354,7 +534,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEditing && isConfirming ? (
|
||||
{!isEditing && isConfirming && !hasAttachedWorktree ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className={cn(
|
||||
'text-xs mr-1',
|
||||
|
||||
Reference in New Issue
Block a user