Skip to content

Commit 69135a4

Browse files
authored
feat: sidebar rework (#193)
![image.png](https://app.graphite.com/user-attachments/assets/ef13f99e-3dc3-43b6-b4f0-82616795f862.png) Also added unread, and generating state. Folders are reoderable. Tasks are automatically sorted on last user interaction with the chat.
1 parent e6b967d commit 69135a4

File tree

11 files changed

+487
-143
lines changed

11 files changed

+487
-143
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useEffect, useState } from "react";
2+
3+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4+
const INTERVAL = 80;
5+
6+
interface DotsCircleSpinnerProps {
7+
size?: number;
8+
className?: string;
9+
}
10+
11+
export function DotsCircleSpinner({
12+
size = 12,
13+
className,
14+
}: DotsCircleSpinnerProps) {
15+
const [frameIndex, setFrameIndex] = useState(0);
16+
17+
useEffect(() => {
18+
const timer = setInterval(() => {
19+
setFrameIndex((prev) => (prev + 1) % FRAMES.length);
20+
}, INTERVAL);
21+
22+
return () => clearInterval(timer);
23+
}, []);
24+
25+
return (
26+
<span
27+
className={className}
28+
style={{
29+
display: "inline-flex",
30+
width: size,
31+
height: size,
32+
alignItems: "center",
33+
justifyContent: "center",
34+
fontSize: size,
35+
lineHeight: 1,
36+
}}
37+
>
38+
{FRAMES[frameIndex]}
39+
</span>
40+
);
41+
}

apps/array/src/renderer/features/sidebar/components/SidebarItem.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { SidebarItemAction } from "../types";
22

3-
const INDENT_SIZE = 12;
3+
const INDENT_SIZE = 8;
44

55
interface SidebarItemProps {
66
depth: number;
@@ -27,27 +27,36 @@ export function SidebarItem({
2727
return (
2828
<button
2929
type="button"
30-
className="focus-visible:-outline-offset-2 flex w-full cursor-pointer items-center border-0 bg-transparent px-2 py-1 text-left font-mono text-[12px] text-gray-11 transition-colors hover:bg-gray-3 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent-8 data-[active]:bg-gray-3"
30+
className="group focus-visible:-outline-offset-2 flex w-full cursor-pointer items-start border-transparent border-y bg-transparent px-2 py-1 text-left font-mono text-[12px] text-gray-11 transition-colors hover:bg-gray-3 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent-8 data-[active]:border-accent-8 data-[active]:bg-accent-4 data-[active]:text-gray-12"
3131
data-active={isActive || undefined}
3232
style={{
3333
paddingLeft: `${depth * INDENT_SIZE + 8}px`,
34-
gap: "6px",
34+
gap: "4px",
3535
}}
3636
onClick={onClick}
3737
onContextMenu={onContextMenu}
3838
>
39-
{icon && <span className="flex shrink-0 items-center">{icon}</span>}
39+
{icon && (
40+
<span
41+
className="flex shrink-0 items-center text-gray-10 group-data-[active]:text-gray-11"
42+
style={{ height: "18px" }}
43+
>
44+
{icon}
45+
</span>
46+
)}
4047
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
41-
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
42-
{label}
48+
<span className="flex items-center gap-1" style={{ height: "18px" }}>
49+
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
50+
{label}
51+
</span>
52+
{endContent}
4353
</span>
4454
{subtitle && (
45-
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-[10px] text-gray-10">
55+
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-[10px] text-gray-10 group-data-[active]:text-gray-11">
4656
{subtitle}
4757
</span>
4858
)}
4959
</span>
50-
{endContent}
5160
</button>
5261
);
5362
}

apps/array/src/renderer/features/sidebar/components/SidebarMenu.tsx

Lines changed: 105 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,41 @@
11
import { RenameTaskDialog } from "@components/RenameTaskDialog";
2+
import type { DragDropEvents } from "@dnd-kit/react";
3+
import { DragDropProvider, DragOverlay, PointerSensor } from "@dnd-kit/react";
24
import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore";
35
import { useTasks } from "@features/tasks/hooks/useTasks";
46
import { useTaskStore } from "@features/tasks/stores/taskStore";
57
import { useMeQuery } from "@hooks/useMeQuery";
68
import { useTaskContextMenu } from "@hooks/useTaskContextMenu";
7-
import { FolderIcon } from "@phosphor-icons/react";
9+
import { FolderIcon, FolderOpenIcon } from "@phosphor-icons/react";
810
import { Box, Flex } from "@radix-ui/themes";
911
import { useRegisteredFoldersStore } from "@renderer/stores/registeredFoldersStore";
1012
import type { Task } from "@shared/types";
1113
import { useNavigationStore } from "@stores/navigationStore";
12-
import { memo } from "react";
14+
import { memo, useCallback } from "react";
1315
import { useWorkspaceStore } from "@/renderer/features/workspace/stores/workspaceStore";
1416
import { useSidebarData } from "../hooks/useSidebarData";
1517
import { useSidebarStore } from "../stores/sidebarStore";
18+
import { useTaskViewedStore } from "../stores/taskViewedStore";
1619
import { HomeItem } from "./items/HomeItem";
1720
import { NewTaskItem } from "./items/NewTaskItem";
18-
import { ProjectsItem } from "./items/ProjectsItem";
1921
import { TaskItem } from "./items/TaskItem";
20-
import { ViewItem } from "./items/ViewItem";
21-
import { SidebarSection } from "./SidebarSection";
22+
import { SortableFolderSection } from "./SortableFolderSection";
2223

2324
function SidebarMenuComponent() {
24-
const { view, navigateToTaskList, navigateToTask, navigateToTaskInput } =
25-
useNavigationStore();
25+
const { view, navigateToTask, navigateToTaskInput } = useNavigationStore();
2626

2727
const activeFilters = useTaskStore((state) => state.activeFilters);
28-
const setActiveFilters = useTaskStore((state) => state.setActiveFilters);
2928
const { data: currentUser } = useMeQuery();
3029
const { data: allTasks = [] } = useTasks();
3130
const { folders, removeFolder } = useRegisteredFoldersStore();
3231

3332
const collapsedSections = useSidebarStore((state) => state.collapsedSections);
3433
const toggleSection = useSidebarStore((state) => state.toggleSection);
34+
const folderOrder = useSidebarStore((state) => state.folderOrder);
35+
const reorderFolders = useSidebarStore((state) => state.reorderFolders);
3536
const workspaces = useWorkspaceStore.use.workspaces();
3637
const taskStates = useTaskExecutionStore((state) => state.taskStates);
38+
const markAsViewed = useTaskViewedStore((state) => state.markAsViewed);
3739

3840
const { showContextMenu, renameTask, renameDialogOpen, setRenameDialogOpen } =
3941
useTaskContextMenu();
@@ -44,6 +46,35 @@ function SidebarMenuComponent() {
4446
currentUser,
4547
});
4648

49+
const handleDragOver: DragDropEvents["dragover"] = useCallback(
50+
(event) => {
51+
const source = event.operation.source;
52+
const target = event.operation.target;
53+
54+
// type is at sortable level, not in data
55+
if (source?.type !== "folder" || target?.type !== "folder") {
56+
return;
57+
}
58+
59+
const sourceId = source?.id;
60+
const targetId = target?.id;
61+
62+
if (!sourceId || !targetId || sourceId === targetId) return;
63+
64+
const sourceIndex = folderOrder.indexOf(String(sourceId));
65+
const targetIndex = folderOrder.indexOf(String(targetId));
66+
67+
if (
68+
sourceIndex !== -1 &&
69+
targetIndex !== -1 &&
70+
sourceIndex !== targetIndex
71+
) {
72+
reorderFolders(sourceIndex, targetIndex);
73+
}
74+
},
75+
[folderOrder, reorderFolders],
76+
);
77+
4778
const taskMap = new Map<string, Task>();
4879
for (const task of allTasks) {
4980
taskMap.set(task.id, task);
@@ -53,21 +84,10 @@ function SidebarMenuComponent() {
5384
navigateToTaskInput();
5485
};
5586

56-
const handleViewClick = (filters: typeof activeFilters) => {
57-
setActiveFilters(filters);
58-
navigateToTaskList();
59-
};
60-
61-
const handleProjectClick = (repository: string) => {
62-
const newFilters = { ...activeFilters };
63-
newFilters.repository = [{ value: repository, operator: "is" }];
64-
setActiveFilters(newFilters);
65-
navigateToTaskList();
66-
};
67-
6887
const handleTaskClick = (taskId: string) => {
6988
const task = taskMap.get(taskId);
7089
if (task) {
90+
markAsViewed(taskId);
7191
navigateToTask(task);
7292
}
7393
};
@@ -135,59 +155,77 @@ function SidebarMenuComponent() {
135155
overflowX: "hidden",
136156
}}
137157
>
138-
<Flex direction="column" p="2">
158+
<Flex direction="column" py="2">
139159
<HomeItem
140160
isActive={sidebarData.isHomeActive}
141161
onClick={handleHomeClick}
142162
/>
143163

144-
{sidebarData.views.map((view) => (
145-
<ViewItem
146-
key={view.id}
147-
label={view.label}
148-
isActive={sidebarData.activeViewId === view.id}
149-
onClick={() => handleViewClick(view.filters)}
150-
/>
151-
))}
152-
153-
<ProjectsItem
154-
repositories={sidebarData.repositories}
155-
isLoading={sidebarData.isLoading}
156-
activeRepository={sidebarData.activeRepository}
157-
onProjectClick={handleProjectClick}
158-
/>
159-
160-
{sidebarData.folders.map((folder, index) => (
161-
<SidebarSection
162-
key={folder.id}
163-
id={folder.id}
164-
label={folder.name}
165-
icon={<FolderIcon size={14} weight="regular" />}
166-
isExpanded={!collapsedSections.has(folder.id)}
167-
onToggle={() => toggleSection(folder.id)}
168-
addSpacingBefore={index === 0}
169-
onContextMenu={(e) => handleFolderContextMenu(folder.id, e)}
170-
>
171-
<NewTaskItem onClick={() => handleFolderNewTask(folder.id)} />
172-
{folder.tasks.map((task) => (
173-
<TaskItem
174-
key={task.id}
175-
id={task.id}
176-
label={task.title}
177-
status={task.status}
178-
isActive={sidebarData.activeTaskId === task.id}
179-
worktreeName={workspaces[task.id]?.worktreeName ?? undefined}
180-
worktreePath={
181-
workspaces[task.id]?.worktreePath ??
182-
workspaces[task.id]?.folderPath
164+
<DragDropProvider
165+
onDragOver={handleDragOver}
166+
sensors={[
167+
PointerSensor.configure({
168+
activationConstraints: {
169+
distance: { value: 5 },
170+
},
171+
}),
172+
]}
173+
>
174+
{sidebarData.folders.map((folder, index) => {
175+
const isExpanded = !collapsedSections.has(folder.id);
176+
return (
177+
<SortableFolderSection
178+
key={folder.id}
179+
id={folder.id}
180+
index={index}
181+
label={folder.name}
182+
icon={
183+
isExpanded ? (
184+
<FolderOpenIcon size={14} weight="regular" />
185+
) : (
186+
<FolderIcon size={14} weight="regular" />
187+
)
183188
}
184-
workspaceMode={taskStates[task.id]?.workspaceMode}
185-
onClick={() => handleTaskClick(task.id)}
186-
onContextMenu={(e) => handleTaskContextMenu(task.id, e)}
187-
/>
188-
))}
189-
</SidebarSection>
190-
))}
189+
isExpanded={isExpanded}
190+
onToggle={() => toggleSection(folder.id)}
191+
onContextMenu={(e) => handleFolderContextMenu(folder.id, e)}
192+
>
193+
<NewTaskItem onClick={() => handleFolderNewTask(folder.id)} />
194+
{folder.tasks.map((task) => (
195+
<TaskItem
196+
key={task.id}
197+
id={task.id}
198+
label={task.title}
199+
isActive={sidebarData.activeTaskId === task.id}
200+
worktreeName={
201+
workspaces[task.id]?.worktreeName ?? undefined
202+
}
203+
worktreePath={
204+
workspaces[task.id]?.worktreePath ??
205+
workspaces[task.id]?.folderPath
206+
}
207+
workspaceMode={taskStates[task.id]?.workspaceMode}
208+
lastActivityAt={task.lastActivityAt}
209+
isGenerating={task.isGenerating}
210+
isUnread={task.isUnread}
211+
onClick={() => handleTaskClick(task.id)}
212+
onContextMenu={(e) => handleTaskContextMenu(task.id, e)}
213+
/>
214+
))}
215+
</SortableFolderSection>
216+
);
217+
})}
218+
<DragOverlay>
219+
{(source) =>
220+
source?.type === "folder" ? (
221+
<div className="flex w-full items-center gap-1 rounded bg-gray-2 px-2 py-1 font-mono text-[12px] text-gray-11 shadow-lg">
222+
<FolderIcon size={14} weight="regular" />
223+
<span className="font-medium">{source.data?.label}</span>
224+
</div>
225+
) : null
226+
}
227+
</DragOverlay>
228+
</DragDropProvider>
191229
</Flex>
192230
</Box>
193231
</>

apps/array/src/renderer/features/sidebar/components/SidebarSection.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,28 @@ export function SidebarSection({
2626
<Collapsible.Trigger asChild>
2727
<button
2828
type="button"
29-
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-2 py-1 text-left font-mono text-[12px] text-gray-12 transition-colors hover:bg-gray-3"
29+
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-2 py-1 text-left font-mono text-[12px] text-gray-11 transition-colors hover:bg-gray-3"
3030
style={{
31-
marginTop: addSpacingBefore ? "16px" : undefined,
31+
marginTop: addSpacingBefore ? "12px" : undefined,
32+
paddingLeft: "8px",
3233
}}
3334
onContextMenu={onContextMenu}
3435
>
35-
<span className="flex flex-1 items-center" style={{ gap: "6px" }}>
36+
<span
37+
className="flex min-w-0 flex-1 items-center"
38+
style={{ gap: "4px" }}
39+
>
3640
{icon && <span className="flex shrink-0 items-center">{icon}</span>}
3741
<span className="overflow-hidden text-ellipsis whitespace-nowrap font-medium">
3842
{label}
3943
</span>
40-
<span className="flex items-center">
41-
{isExpanded ? (
42-
<CaretDownIcon size={12} weight="fill" />
43-
) : (
44-
<CaretRightIcon size={12} weight="fill" />
45-
)}
46-
</span>
44+
</span>
45+
<span className="flex shrink-0 items-center text-gray-10">
46+
{isExpanded ? (
47+
<CaretDownIcon size={12} />
48+
) : (
49+
<CaretRightIcon size={12} />
50+
)}
4751
</span>
4852
</button>
4953
</Collapsible.Trigger>

0 commit comments

Comments
 (0)