Skip to content

Commit 9255811

Browse files
BunsDevclaude
andauthored
Unify right panel with tabbed Files/Editor/Diffs views (#331)
Move the workspace file tree from the left sidebar into a unified right panel alongside the code editor and diff viewer. Add a minimal tab header with icon+label toggle buttons to switch between panel views, inspired by the Vercel PR diff UI. The three sub-panels share a single resizable sidebar with coordinated state via a new Zustand store. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eb6343d commit 9255811

7 files changed

Lines changed: 421 additions & 365 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { CodeIcon, FolderIcon, GitCompareIcon, PanelRightCloseIcon } from "lucide-react";
2+
import { memo } from "react";
3+
import { isElectron } from "~/env";
4+
import { cn } from "~/lib/utils";
5+
import { type RightPanelTab, useRightPanelStore } from "~/rightPanelStore";
6+
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
7+
8+
const TABS: readonly {
9+
id: RightPanelTab;
10+
label: string;
11+
icon: typeof FolderIcon;
12+
}[] = [
13+
{ id: "files", label: "Files", icon: FolderIcon },
14+
{ id: "editor", label: "Editor", icon: CodeIcon },
15+
{ id: "diffs", label: "Diffs", icon: GitCompareIcon },
16+
];
17+
18+
export const RightPanelHeader = memo(function RightPanelHeader() {
19+
const activeTab = useRightPanelStore((s) => s.activeTab);
20+
const setActiveTab = useRightPanelStore((s) => s.setActiveTab);
21+
const close = useRightPanelStore((s) => s.close);
22+
23+
return (
24+
<div
25+
className={cn(
26+
"flex items-center justify-between border-b border-border/60 px-2",
27+
isElectron ? "drag-region h-[52px]" : "h-10",
28+
)}
29+
>
30+
<div className="flex items-center gap-0.5 [-webkit-app-region:no-drag]">
31+
{TABS.map((tab) => {
32+
const Icon = tab.icon;
33+
const isActive = activeTab === tab.id;
34+
return (
35+
<Tooltip key={tab.id}>
36+
<TooltipTrigger
37+
render={<button type="button" />}
38+
className={cn(
39+
"flex h-6 items-center gap-1.5 rounded-md px-2 text-[11px] font-medium transition-colors",
40+
isActive
41+
? "bg-accent text-accent-foreground"
42+
: "text-muted-foreground/60 hover:bg-accent/40 hover:text-foreground/80",
43+
)}
44+
onClick={() => {
45+
if (isActive) {
46+
close();
47+
} else {
48+
setActiveTab(tab.id);
49+
}
50+
}}
51+
>
52+
<Icon className="size-3.5" />
53+
<span className={cn("hidden sm:inline", isActive && "inline")}>{tab.label}</span>
54+
</TooltipTrigger>
55+
<TooltipPopup side="bottom" sideOffset={6}>
56+
{tab.label}
57+
</TooltipPopup>
58+
</Tooltip>
59+
);
60+
})}
61+
</div>
62+
<Tooltip>
63+
<TooltipTrigger
64+
render={<button type="button" />}
65+
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-accent/40 hover:text-foreground/80 [-webkit-app-region:no-drag]"
66+
onClick={close}
67+
aria-label="Close panel"
68+
>
69+
<PanelRightCloseIcon className="size-3.5" />
70+
</TooltipTrigger>
71+
<TooltipPopup side="bottom" sideOffset={6}>
72+
Close panel
73+
</TooltipPopup>
74+
</Tooltip>
75+
</div>
76+
);
77+
});

apps/web/src/components/Sidebar.tsx

Lines changed: 78 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,113 @@
11
import {
2-
ArrowLeftIcon,
3-
ArrowUpDownIcon,
4-
CheckCircleIcon,
5-
ChevronRightIcon,
6-
CircleDotIcon,
7-
CloudUploadIcon,
8-
EyeIcon,
9-
EyeOffIcon,
10-
ExternalLinkIcon,
11-
FolderIcon,
12-
GitBranchIcon,
13-
GitMergeIcon,
14-
GitPullRequestIcon,
15-
LinkIcon,
16-
UserIcon,
17-
XCircleIcon,
18-
PanelLeftCloseIcon,
19-
PlusIcon,
20-
RocketIcon,
21-
SettingsIcon,
22-
TriangleAlertIcon,
23-
} from "lucide-react";
24-
import { OkCodeMark } from "./OkCodeMark";
25-
import { memo, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
26-
import {
2+
type CollisionDetection,
3+
closestCorners,
274
DndContext,
285
type DragCancelEvent,
29-
type CollisionDetection,
30-
PointerSensor,
6+
type DragEndEvent,
317
type DragStartEvent,
32-
closestCorners,
8+
PointerSensor,
339
pointerWithin,
3410
useSensor,
3511
useSensors,
36-
type DragEndEvent,
3712
} from "@dnd-kit/core";
38-
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
3913
import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
14+
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
4015
import { CSS } from "@dnd-kit/utilities";
16+
import type { ThreadId as ThreadIdType } from "@okcode/contracts";
4117
import {
4218
DEFAULT_MODEL_BY_PROVIDER,
4319
type DesktopUpdateState,
44-
ProjectId,
45-
ThreadId,
4620
type GitStatusResult,
21+
type ProjectId,
4722
type ResolvedKeybindingsConfig,
23+
ThreadId,
4824
} from "@okcode/contracts";
4925
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
5026
import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
27+
import { isNonEmpty as isNonEmptyString } from "effect/String";
28+
import {
29+
ArrowLeftIcon,
30+
ArrowUpDownIcon,
31+
CheckCircleIcon,
32+
CircleDotIcon,
33+
CloudUploadIcon,
34+
ExternalLinkIcon,
35+
FolderIcon,
36+
GitBranchIcon,
37+
GitMergeIcon,
38+
GitPullRequestIcon,
39+
LinkIcon,
40+
PanelLeftCloseIcon,
41+
PlusIcon,
42+
RocketIcon,
43+
SettingsIcon,
44+
TriangleAlertIcon,
45+
UserIcon,
46+
XCircleIcon,
47+
} from "lucide-react";
48+
import { type MouseEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
49+
import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog";
50+
import { EditableThreadTitle } from "~/components/EditableThreadTitle";
51+
import { useClientMode } from "~/hooks/useClientMode";
52+
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
53+
import { useProjectTitleEditor } from "~/hooks/useProjectTitleEditor";
54+
import { useTheme } from "~/hooks/useTheme";
55+
import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor";
56+
import { resolveImportedProjectScripts } from "~/lib/projectImport";
57+
import { getProjectColor } from "~/projectColors";
58+
import { useRightPanelStore } from "~/rightPanelStore";
5159
import {
5260
type SidebarProjectSortOrder,
5361
type SidebarThreadSortOrder,
5462
useAppSettings,
5563
} from "../appSettings";
56-
import { isElectron } from "../env";
5764
import { APP_BASE_NAME, APP_VERSION } from "../branding";
58-
import { cn, isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
59-
import { useStore } from "../store";
65+
import { useComposerDraftStore } from "../composerDraftStore";
66+
import { isElectron } from "../env";
67+
import { useHandleNewThread } from "../hooks/useHandleNewThread";
6068
import { shortcutLabelForCommand } from "../keybindings";
61-
import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
6269
import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
70+
import { resolveServerHttpOrigin } from "../lib/runtimeBridge";
6371
import { serverConfigQueryOptions, serverUpdateQueryOptions } from "../lib/serverReactQuery";
72+
import { cn, isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
6473
import { readNativeApi } from "../nativeApi";
65-
import { resolveServerHttpOrigin } from "../lib/runtimeBridge";
66-
import { useComposerDraftStore } from "../composerDraftStore";
67-
import { useHandleNewThread } from "../hooks/useHandleNewThread";
74+
import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
75+
import { useStore } from "../store";
6876
import {
6977
selectThreadTerminalState,
7078
type ThreadTerminalState,
7179
useTerminalStateStore,
7280
} from "../terminalStateStore";
73-
import { toastManager } from "./ui/toast";
81+
import { useThreadSelectionStore } from "../threadSelectionStore";
82+
import type { Thread } from "../types";
83+
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
7484
import {
7585
getArm64IntelBuildWarningDescription,
7686
getDesktopUpdateActionError,
7787
getDesktopUpdateButtonTooltip,
7888
isDesktopUpdateButtonDisabled,
7989
resolveDesktopUpdateButtonAction,
80-
shouldShowArm64IntelBuildWarning,
8190
shouldHighlightDesktopUpdateError,
91+
shouldShowArm64IntelBuildWarning,
8292
shouldShowDesktopUpdateButton,
8393
shouldToastDesktopUpdateActionResult,
8494
} from "./desktopUpdate.logic";
95+
import { OkCodeMark } from "./OkCodeMark";
96+
import {
97+
computeProjectDisambiguationPaths,
98+
getVisibleThreadsForProject,
99+
isActionableThreadStatus,
100+
resolveProjectStatusIndicator,
101+
resolveSidebarNewThreadEnvMode,
102+
resolveThreadStatusPill,
103+
shouldClearThreadSelectionOnMouseDown,
104+
sortProjectsForSidebar,
105+
sortThreadsForSidebar,
106+
} from "./Sidebar.logic";
85107
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
86108
import { Button } from "./ui/button";
87109
import { Collapsible, CollapsibleContent } from "./ui/collapsible";
88110
import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
89-
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
90111
import {
91112
SidebarContent,
92113
SidebarFooter,
@@ -102,32 +123,8 @@ import {
102123
SidebarTrigger,
103124
useSidebar,
104125
} from "./ui/sidebar";
105-
import { useThreadSelectionStore } from "../threadSelectionStore";
106-
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
107-
import { isNonEmpty as isNonEmptyString } from "effect/String";
108-
import { useTheme } from "~/hooks/useTheme";
109-
import {
110-
computeProjectDisambiguationPaths,
111-
getVisibleThreadsForProject,
112-
isActionableThreadStatus,
113-
resolveProjectStatusIndicator,
114-
resolveSidebarNewThreadEnvMode,
115-
resolveThreadStatusPill,
116-
shouldClearThreadSelectionOnMouseDown,
117-
sortProjectsForSidebar,
118-
sortThreadsForSidebar,
119-
} from "./Sidebar.logic";
120-
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
121-
import { WorkspaceFileTree } from "~/components/WorkspaceFileTree";
122-
import { EditableThreadTitle } from "~/components/EditableThreadTitle";
123-
import { useProjectTitleEditor } from "~/hooks/useProjectTitleEditor";
124-
import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor";
125-
import { resolveImportedProjectScripts } from "~/lib/projectImport";
126-
import { useClientMode } from "~/hooks/useClientMode";
127-
import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog";
128-
import { getProjectColor } from "~/projectColors";
129-
import type { Thread } from "../types";
130-
import type { ThreadId as ThreadIdType } from "@okcode/contracts";
126+
import { toastManager } from "./ui/toast";
127+
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
131128

132129
const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
133130
const THREAD_PREVIEW_LIMIT = 10;
@@ -604,9 +601,6 @@ export default function Sidebar() {
604601
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
605602
ReadonlySet<ProjectId>
606603
>(() => new Set());
607-
const [filesCollapsedByProject, setFilesCollapsedByProject] = useState<ReadonlySet<ProjectId>>(
608-
() => new Set(),
609-
);
610604
const dragInProgressRef = useRef(false);
611605
const suppressProjectClickAfterDragRef = useRef(false);
612606
const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
@@ -986,7 +980,9 @@ export default function Sidebar() {
986980
],
987981
);
988982

989-
const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
983+
const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
984+
threadId: ThreadId;
985+
}>({
990986
onCopy: (ctx) => {
991987
toastManager.add({
992988
type: "success",
@@ -1002,7 +998,9 @@ export default function Sidebar() {
1002998
});
1003999
},
10041000
});
1005-
const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
1001+
const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
1002+
path: string;
1003+
}>({
10061004
onCopy: () => {
10071005
toastManager.add({
10081006
type: "success",
@@ -1373,7 +1371,6 @@ export default function Sidebar() {
13731371
const activeProjectThread = activeThreadId
13741372
? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null)
13751373
: null;
1376-
const activeWorkspaceCwd = activeProjectThread?.worktreePath ?? project.cwd;
13771374
const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
13781375
const pinnedCollapsedThread =
13791376
!project.expanded && activeThreadId
@@ -1541,41 +1538,6 @@ export default function Sidebar() {
15411538
</SidebarMenuSubItem>
15421539
)}
15431540
</SidebarMenuSub>
1544-
1545-
{project.expanded && !appSettings.sidebarHideFiles ? (
1546-
<div className="mx-1 mt-0.5 px-1">
1547-
<button
1548-
type="button"
1549-
className="mb-1 flex w-full items-center gap-1.5 px-2 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/50 hover:text-muted-foreground/70"
1550-
onClick={() =>
1551-
setFilesCollapsedByProject((current) => {
1552-
const next = new Set(current);
1553-
if (next.has(project.id)) {
1554-
next.delete(project.id);
1555-
} else {
1556-
next.add(project.id);
1557-
}
1558-
return next;
1559-
})
1560-
}
1561-
>
1562-
<ChevronRightIcon
1563-
className={cn(
1564-
"size-3 shrink-0 transition-transform",
1565-
!filesCollapsedByProject.has(project.id) && "rotate-90",
1566-
)}
1567-
/>
1568-
<FolderIcon className="size-3 shrink-0" />
1569-
<span>Files</span>
1570-
</button>
1571-
<WorkspaceFileTree
1572-
key={project.id}
1573-
cwd={activeWorkspaceCwd}
1574-
resolvedTheme={resolvedTheme}
1575-
className={cn(filesCollapsedByProject.has(project.id) && "hidden")}
1576-
/>
1577-
</div>
1578-
) : null}
15791541
</CollapsibleContent>
15801542
</Collapsible>
15811543
);
@@ -2004,29 +1966,17 @@ export default function Sidebar() {
20041966
render={
20051967
<button
20061968
type="button"
2007-
aria-label={appSettings.sidebarHideFiles ? "Show files" : "Hide files"}
2008-
aria-pressed={appSettings.sidebarHideFiles}
2009-
className={cn(
2010-
"inline-flex size-5 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground",
2011-
appSettings.sidebarHideFiles
2012-
? "text-muted-foreground/40"
2013-
: "text-muted-foreground/60",
2014-
)}
2015-
onClick={() =>
2016-
updateSettings({ sidebarHideFiles: !appSettings.sidebarHideFiles })
2017-
}
1969+
aria-label="Open file tree"
1970+
className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
1971+
onClick={() => {
1972+
useRightPanelStore.getState().open("files");
1973+
}}
20181974
/>
20191975
}
20201976
>
2021-
{appSettings.sidebarHideFiles ? (
2022-
<EyeOffIcon className="size-3.5" />
2023-
) : (
2024-
<EyeIcon className="size-3.5" />
2025-
)}
1977+
<FolderIcon className="size-3.5" />
20261978
</TooltipTrigger>
2027-
<TooltipPopup side="top">
2028-
{appSettings.sidebarHideFiles ? "Show files" : "Hide files"}
2029-
</TooltipPopup>
1979+
<TooltipPopup side="top">Open file tree</TooltipPopup>
20301980
</Tooltip>
20311981
<ProjectSortMenu
20321982
projectSortOrder={appSettings.sidebarProjectSortOrder}

0 commit comments

Comments
 (0)