From d7a8652067e36554bb1a77334ed252877d02a87e Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 12 Jan 2026 21:55:35 -0800 Subject: [PATCH 1/2] feat: New history based side nav --- .../sidebar/components/HistoryView.tsx | 136 ++++++++++++++ .../sidebar/components/SidebarFooter.tsx | 35 +++- .../sidebar/components/SidebarMenu.tsx | 174 ++++++++++-------- .../sidebar/components/ViewModeSelector.tsx | 62 +++++++ .../features/sidebar/hooks/useSidebarData.ts | 99 ++++++++++ .../features/sidebar/stores/sidebarStore.ts | 22 +++ 6 files changed, 441 insertions(+), 87 deletions(-) create mode 100644 apps/array/src/renderer/features/sidebar/components/HistoryView.tsx create mode 100644 apps/array/src/renderer/features/sidebar/components/ViewModeSelector.tsx diff --git a/apps/array/src/renderer/features/sidebar/components/HistoryView.tsx b/apps/array/src/renderer/features/sidebar/components/HistoryView.tsx new file mode 100644 index 000000000..83106aa80 --- /dev/null +++ b/apps/array/src/renderer/features/sidebar/components/HistoryView.tsx @@ -0,0 +1,136 @@ +import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore"; +import { Button, Flex } from "@radix-ui/themes"; +import { useWorkspaceStore } from "@/renderer/features/workspace/stores/workspaceStore"; +import type { HistoryData, HistoryTaskData } from "../hooks/useSidebarData"; +import { useSidebarStore } from "../stores/sidebarStore"; +import { TaskItem } from "./items/TaskItem"; + +interface HistoryViewProps { + historyData: HistoryData; + activeTaskId: string | null; + onTaskClick: (taskId: string) => void; + onTaskContextMenu: (taskId: string, e: React.MouseEvent) => void; + onTaskDelete: (taskId: string) => void; + onTaskTogglePin: (taskId: string) => void; +} + +function HistorySectionLabel({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +interface HistoryTaskItemProps { + task: HistoryTaskData; + isActive: boolean; + onClick: () => void; + onContextMenu: (e: React.MouseEvent) => void; + onDelete: () => void; + onTogglePin: () => void; +} + +function HistoryTaskItem({ + task, + isActive, + onClick, + onContextMenu, + onDelete, + onTogglePin, +}: HistoryTaskItemProps) { + const workspaces = useWorkspaceStore.use.workspaces(); + const taskStates = useTaskExecutionStore((state) => state.taskStates); + + const workspace = workspaces[task.id]; + const taskState = taskStates[task.id]; + + return ( + + ); +} + +export function HistoryView({ + historyData, + activeTaskId, + onTaskClick, + onTaskContextMenu, + onTaskDelete, + onTaskTogglePin, +}: HistoryViewProps) { + const loadMoreHistory = useSidebarStore((state) => state.loadMoreHistory); + const { activeTasks, recentTasks, hasMore } = historyData; + + const hasActiveTasks = activeTasks.length > 0; + const hasRecentTasks = recentTasks.length > 0; + + return ( + + {hasActiveTasks && ( + <> + + {activeTasks.map((task) => ( + onTaskClick(task.id)} + onContextMenu={(e) => onTaskContextMenu(task.id, e)} + onDelete={() => onTaskDelete(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + /> + ))} + {hasRecentTasks && ( +
+ )} + + )} + + {hasRecentTasks && ( + <> + + {recentTasks.map((task) => ( + onTaskClick(task.id)} + onContextMenu={(e) => onTaskContextMenu(task.id, e)} + onDelete={() => onTaskDelete(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + /> + ))} + + )} + + {hasMore && ( +
+ +
+ )} + + ); +} diff --git a/apps/array/src/renderer/features/sidebar/components/SidebarFooter.tsx b/apps/array/src/renderer/features/sidebar/components/SidebarFooter.tsx index 9e1f3536c..907df20f7 100644 --- a/apps/array/src/renderer/features/sidebar/components/SidebarFooter.tsx +++ b/apps/array/src/renderer/features/sidebar/components/SidebarFooter.tsx @@ -5,10 +5,12 @@ import { useRegisteredFoldersStore } from "@renderer/stores/registeredFoldersSto import { trpcVanilla } from "@renderer/trpc"; import { useNavigationStore } from "@stores/navigationStore"; import { useCallback } from "react"; +import { useSidebarStore } from "../stores/sidebarStore"; export function SidebarFooter() { const addFolder = useRegisteredFoldersStore((state) => state.addFolder); - const { toggleSettings } = useNavigationStore(); + const { toggleSettings, navigateToTaskInput } = useNavigationStore(); + const viewMode = useSidebarStore((state) => state.viewMode); const handleAddRepository = useCallback(async () => { const selectedPath = await trpcVanilla.os.selectDirectory.query(); @@ -17,6 +19,12 @@ export function SidebarFooter() { } }, [addFolder]); + const handleNewTask = useCallback(() => { + navigateToTaskInput(); + }, [navigateToTaskInput]); + + const isHistoryView = viewMode === "history"; + return ( - + {isHistoryView ? ( + + ) : ( + + )} state.toggleSection); const folderOrder = useSidebarStore((state) => state.folderOrder); const reorderFolders = useSidebarStore((state) => state.reorderFolders); + const viewMode = useSidebarStore((state) => state.viewMode); const workspaces = useWorkspaceStore.use.workspaces(); const taskStates = useTaskExecutionStore((state) => state.taskStates); const markAsViewed = useTaskViewedStore((state) => state.markAsViewed); @@ -196,87 +199,104 @@ function SidebarMenuComponent() { onClick={handleHomeClick} /> +
+ +
+
- - {sidebarData.folders.map((folder, index) => { - const isExpanded = !collapsedSections.has(folder.id); - return ( -
- {index > 0 && ( -
- )} - - ) : ( - - ) - } - isExpanded={isExpanded} - onToggle={() => toggleSection(folder.id)} - onSettingsClick={() => handleFolderSettings(folder.id)} - onContextMenu={(e) => - handleFolderContextMenu(folder.id, e) - } - > - handleFolderNewTask(folder.id)} - /> - {folder.tasks.map((task) => ( - handleTaskClick(task.id)} - onContextMenu={(e) => - handleTaskContextMenu(task.id, e) - } - onDelete={() => handleTaskDelete(task.id)} - onTogglePin={() => handleTaskTogglePin(task.id)} + {viewMode === "history" ? ( + + ) : ( + + {sidebarData.folders.map((folder, index) => { + const isExpanded = !collapsedSections.has(folder.id); + return ( +
+ {index > 0 && ( +
+ )} + + ) : ( + + ) + } + isExpanded={isExpanded} + onToggle={() => toggleSection(folder.id)} + onSettingsClick={() => handleFolderSettings(folder.id)} + onContextMenu={(e) => + handleFolderContextMenu(folder.id, e) + } + > + handleFolderNewTask(folder.id)} /> - ))} - -
- ); - })} - - {(source) => - source?.type === "folder" ? ( -
- - {source.data?.label} + {folder.tasks.map((task) => ( + handleTaskClick(task.id)} + onContextMenu={(e) => + handleTaskContextMenu(task.id, e) + } + onDelete={() => handleTaskDelete(task.id)} + onTogglePin={() => handleTaskTogglePin(task.id)} + /> + ))} +
- ) : null - } -
- + ); + })} + + {(source) => + source?.type === "folder" ? ( +
+ + + {source.data?.label} + +
+ ) : null + } +
+ + )} diff --git a/apps/array/src/renderer/features/sidebar/components/ViewModeSelector.tsx b/apps/array/src/renderer/features/sidebar/components/ViewModeSelector.tsx new file mode 100644 index 000000000..0530a3a7a --- /dev/null +++ b/apps/array/src/renderer/features/sidebar/components/ViewModeSelector.tsx @@ -0,0 +1,62 @@ +import { ClockCounterClockwise, Folder } from "@phosphor-icons/react"; +import { Select, Text } from "@radix-ui/themes"; +import { type SidebarViewMode, useSidebarStore } from "../stores/sidebarStore"; + +const VIEW_OPTIONS = [ + { value: "folders" as const, label: "Repositories", Icon: Folder }, + { value: "history" as const, label: "History", Icon: ClockCounterClockwise }, +]; + +export function ViewModeSelector() { + const viewMode = useSidebarStore((state) => state.viewMode); + const setViewMode = useSidebarStore((state) => state.setViewMode); + const resetHistoryVisibleCount = useSidebarStore( + (state) => state.resetHistoryVisibleCount, + ); + + const handleChange = (value: SidebarViewMode) => { + if (value === "history") { + resetHistoryVisibleCount(); + } + setViewMode(value); + }; + + const currentOption = VIEW_OPTIONS.find((o) => o.value === viewMode); + const CurrentIcon = currentOption?.Icon ?? Folder; + + return ( + + + + + + {currentOption?.label} + + + + + {VIEW_OPTIONS.map((option) => { + const OptionIcon = option.Icon; + return ( + + + + {option.label} + + + ); + })} + + + ); +} diff --git a/apps/array/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/array/src/renderer/features/sidebar/hooks/useSidebarData.ts index 7144b3f38..28d788496 100644 --- a/apps/array/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/array/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -46,6 +46,18 @@ export interface TaskData { isPinned?: boolean; } +export interface HistoryTaskData extends TaskData { + createdAt: number; + folderName?: string; +} + +export interface HistoryData { + activeTasks: HistoryTaskData[]; + recentTasks: HistoryTaskData[]; + totalCount: number; + hasMore: boolean; +} + export interface SidebarData { userName: string; isHomeActive: boolean; @@ -56,6 +68,7 @@ export interface SidebarData { isLoading: boolean; folders: FolderData[]; activeTaskId: string | null; + historyData: HistoryData; } interface ViewState { @@ -170,6 +183,76 @@ function getActiveRepository(activeFilters: ActiveFilters): string | null { return repositoryFilters.length === 1 ? repositoryFilters[0].value : null; } +function buildHistoryData( + allTasks: Task[], + workspaces: Record, + folders: RegisteredFolder[], + sessions: Record, + lastViewedAt: Record, + localActivityAt: Record, + pinnedTaskIds: Set, + activeTaskId: string | null, + visibleCount: number, +): HistoryData { + const getSessionForTask = (taskId: string): AgentSession | undefined => { + return Object.values(sessions).find((s) => s.taskId === taskId); + }; + + // Transform all tasks to HistoryTaskData + const historyTasks: HistoryTaskData[] = allTasks.map((task) => { + const session = getSessionForTask(task.id); + const workspace = workspaces[task.id]; + const folder = workspace + ? folders.find((f) => f.id === workspace.folderId) + : undefined; + + const apiUpdatedAt = new Date(task.updated_at).getTime(); + const localActivity = localActivityAt[task.id]; + const lastActivityAt = localActivity + ? Math.max(apiUpdatedAt, localActivity) + : apiUpdatedAt; + + const taskLastViewedAt = lastViewedAt[task.id]; + const isCurrentlyViewing = activeTaskId === task.id; + const isUnread = + !isCurrentlyViewing && + taskLastViewedAt !== undefined && + lastActivityAt > taskLastViewedAt; + + return { + id: task.id, + title: task.title, + lastActivityAt, + createdAt: new Date(task.created_at).getTime(), + isGenerating: session?.isPromptPending ?? false, + isUnread, + isPinned: pinnedTaskIds.has(task.id), + folderName: folder?.name, + }; + }); + + // Partition into active (unread) and inactive tasks + const activeTasks = historyTasks + .filter((t) => t.isUnread) + .sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0)); + + const inactiveTasks = historyTasks + .filter((t) => !t.isUnread) + .sort((a, b) => b.createdAt - a.createdAt); + + // Apply pagination to inactive tasks only (active always shown) + const totalCount = allTasks.length; + const recentTasks = inactiveTasks.slice(0, visibleCount); + const hasMore = inactiveTasks.length > visibleCount; + + return { + activeTasks, + recentTasks, + totalCount, + hasMore, + }; +} + export function useSidebarData({ activeView, activeFilters, @@ -183,6 +266,9 @@ export function useSidebarData({ const localActivityAt = useTaskViewedStore((state) => state.lastActivityAt); const folderOrder = useSidebarStore((state) => state.folderOrder); const syncFolderOrder = useSidebarStore((state) => state.syncFolderOrder); + const historyVisibleCount = useSidebarStore( + (state) => state.historyVisibleCount, + ); const pinnedTaskIds = usePinnedTasksStore((state) => state.pinnedTaskIds); const userName = currentUser?.first_name || currentUser?.email || "Account"; @@ -269,6 +355,18 @@ export function useSidebarData({ }; }); + const historyData = buildHistoryData( + allTasks, + workspaces, + folders, + sessions, + lastViewedAt, + localActivityAt, + pinnedTaskIds, + activeTaskId, + historyVisibleCount, + ); + return { userName, isHomeActive, @@ -279,5 +377,6 @@ export function useSidebarData({ isLoading, folders: folderData, activeTaskId, + historyData, }; } diff --git a/apps/array/src/renderer/features/sidebar/stores/sidebarStore.ts b/apps/array/src/renderer/features/sidebar/stores/sidebarStore.ts index a6d851789..aa46e7165 100644 --- a/apps/array/src/renderer/features/sidebar/stores/sidebarStore.ts +++ b/apps/array/src/renderer/features/sidebar/stores/sidebarStore.ts @@ -1,6 +1,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +export type SidebarViewMode = "folders" | "history"; + interface SidebarStoreState { open: boolean; hasUserSetOpen: boolean; @@ -8,6 +10,8 @@ interface SidebarStoreState { isResizing: boolean; collapsedSections: Set; folderOrder: string[]; + viewMode: SidebarViewMode; + historyVisibleCount: number; } interface SidebarStoreActions { @@ -20,6 +24,9 @@ interface SidebarStoreActions { reorderFolders: (fromIndex: number, toIndex: number) => void; setFolderOrder: (order: string[]) => void; syncFolderOrder: (folderIds: string[]) => void; + setViewMode: (mode: SidebarViewMode) => void; + loadMoreHistory: () => void; + resetHistoryVisibleCount: () => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; @@ -33,6 +40,8 @@ export const useSidebarStore = create()( isResizing: false, collapsedSections: new Set(), folderOrder: [], + viewMode: "history" as SidebarViewMode, + historyVisibleCount: 25, setOpen: (open) => set({ open, hasUserSetOpen: true }), setOpenAuto: (open) => set((state) => (state.hasUserSetOpen ? state : { open })), @@ -74,6 +83,12 @@ export const useSidebarStore = create()( } return state; }), + setViewMode: (mode) => set({ viewMode: mode }), + loadMoreHistory: () => + set((state) => ({ + historyVisibleCount: state.historyVisibleCount + 25, + })), + resetHistoryVisibleCount: () => set({ historyVisibleCount: 25 }), }), { name: "sidebar-storage", @@ -83,6 +98,8 @@ export const useSidebarStore = create()( width: state.width, collapsedSections: Array.from(state.collapsedSections), folderOrder: state.folderOrder, + viewMode: state.viewMode, + historyVisibleCount: state.historyVisibleCount, }), merge: (persisted, current) => { const persistedState = persisted as { @@ -91,6 +108,8 @@ export const useSidebarStore = create()( width?: number; collapsedSections?: string[]; folderOrder?: string[]; + viewMode?: SidebarViewMode; + historyVisibleCount?: number; }; return { ...current, @@ -100,6 +119,9 @@ export const useSidebarStore = create()( width: persistedState.width ?? current.width, collapsedSections: new Set(persistedState.collapsedSections ?? []), folderOrder: persistedState.folderOrder ?? [], + viewMode: persistedState.viewMode ?? current.viewMode, + historyVisibleCount: + persistedState.historyVisibleCount ?? current.historyVisibleCount, }; }, }, From a822df8bac681e3bc6bc6d87381a3eb7844bd5f0 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 12 Jan 2026 22:27:19 -0800 Subject: [PATCH 2/2] feat: New pinned sidenav --- .../sidebar/components/PinnedView.tsx | 105 ++++++++++++++++++ .../sidebar/components/SidebarFooter.tsx | 4 +- .../sidebar/components/SidebarMenu.tsx | 18 ++- .../sidebar/components/ViewModeSelector.tsx | 5 +- .../features/sidebar/hooks/useSidebarData.ts | 63 +++++++++++ .../features/sidebar/stores/sidebarStore.ts | 2 +- 6 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 apps/array/src/renderer/features/sidebar/components/PinnedView.tsx diff --git a/apps/array/src/renderer/features/sidebar/components/PinnedView.tsx b/apps/array/src/renderer/features/sidebar/components/PinnedView.tsx new file mode 100644 index 000000000..bc22e7563 --- /dev/null +++ b/apps/array/src/renderer/features/sidebar/components/PinnedView.tsx @@ -0,0 +1,105 @@ +import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore"; +import { PushPin } from "@phosphor-icons/react"; +import { Flex } from "@radix-ui/themes"; +import { useWorkspaceStore } from "@/renderer/features/workspace/stores/workspaceStore"; +import type { PinnedData, TaskData } from "../hooks/useSidebarData"; +import { TaskItem } from "./items/TaskItem"; + +interface PinnedViewProps { + pinnedData: PinnedData; + activeTaskId: string | null; + onTaskClick: (taskId: string) => void; + onTaskContextMenu: (taskId: string, e: React.MouseEvent) => void; + onTaskDelete: (taskId: string) => void; + onTaskTogglePin: (taskId: string) => void; +} + +function PinnedTaskItem({ + task, + isActive, + onClick, + onContextMenu, + onDelete, + onTogglePin, +}: { + task: TaskData; + isActive: boolean; + onClick: () => void; + onContextMenu: (e: React.MouseEvent) => void; + onDelete: () => void; + onTogglePin: () => void; +}) { + const workspaces = useWorkspaceStore.use.workspaces(); + const taskStates = useTaskExecutionStore((state) => state.taskStates); + + const workspace = workspaces[task.id]; + const taskState = taskStates[task.id]; + + return ( + + ); +} + +export function PinnedView({ + pinnedData, + activeTaskId, + onTaskClick, + onTaskContextMenu, + onTaskDelete, + onTaskTogglePin, +}: PinnedViewProps) { + const { tasks } = pinnedData; + + if (tasks.length === 0) { + return ( + + + No pinned tasks + + Pin tasks from any view to quickly access them here + + + ); + } + + return ( + +
+ Pinned ({tasks.length}) +
+ {tasks.map((task) => ( + onTaskClick(task.id)} + onContextMenu={(e) => onTaskContextMenu(task.id, e)} + onDelete={() => onTaskDelete(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + /> + ))} +
+ ); +} diff --git a/apps/array/src/renderer/features/sidebar/components/SidebarFooter.tsx b/apps/array/src/renderer/features/sidebar/components/SidebarFooter.tsx index 907df20f7..6e4173168 100644 --- a/apps/array/src/renderer/features/sidebar/components/SidebarFooter.tsx +++ b/apps/array/src/renderer/features/sidebar/components/SidebarFooter.tsx @@ -23,7 +23,7 @@ export function SidebarFooter() { navigateToTaskInput(); }, [navigateToTaskInput]); - const isHistoryView = viewMode === "history"; + const showNewTaskButton = viewMode !== "folders"; return ( - {isHistoryView ? ( + {showNewTaskButton ? (