diff --git a/apps/array/src/renderer/components/DotsCircleSpinner.tsx b/apps/array/src/renderer/components/DotsCircleSpinner.tsx new file mode 100644 index 00000000..a8e091b0 --- /dev/null +++ b/apps/array/src/renderer/components/DotsCircleSpinner.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; + +const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const INTERVAL = 80; + +interface DotsCircleSpinnerProps { + size?: number; + className?: string; +} + +export function DotsCircleSpinner({ + size = 12, + className, +}: DotsCircleSpinnerProps) { + const [frameIndex, setFrameIndex] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setFrameIndex((prev) => (prev + 1) % FRAMES.length); + }, INTERVAL); + + return () => clearInterval(timer); + }, []); + + return ( + + {FRAMES[frameIndex]} + + ); +} diff --git a/apps/array/src/renderer/features/sidebar/components/SidebarItem.tsx b/apps/array/src/renderer/features/sidebar/components/SidebarItem.tsx index dbf5cd31..a8d5949b 100644 --- a/apps/array/src/renderer/features/sidebar/components/SidebarItem.tsx +++ b/apps/array/src/renderer/features/sidebar/components/SidebarItem.tsx @@ -1,6 +1,6 @@ import type { SidebarItemAction } from "../types"; -const INDENT_SIZE = 12; +const INDENT_SIZE = 8; interface SidebarItemProps { depth: number; @@ -27,27 +27,36 @@ export function SidebarItem({ return ( ); } diff --git a/apps/array/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/array/src/renderer/features/sidebar/components/SidebarMenu.tsx index ce8cb0b8..93e0fe6c 100644 --- a/apps/array/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/array/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -1,39 +1,41 @@ import { RenameTaskDialog } from "@components/RenameTaskDialog"; +import type { DragDropEvents } from "@dnd-kit/react"; +import { DragDropProvider, DragOverlay, PointerSensor } from "@dnd-kit/react"; import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { useTaskStore } from "@features/tasks/stores/taskStore"; import { useMeQuery } from "@hooks/useMeQuery"; import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; -import { FolderIcon } from "@phosphor-icons/react"; +import { FolderIcon, FolderOpenIcon } from "@phosphor-icons/react"; import { Box, Flex } from "@radix-ui/themes"; import { useRegisteredFoldersStore } from "@renderer/stores/registeredFoldersStore"; import type { Task } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; -import { memo } from "react"; +import { memo, useCallback } from "react"; import { useWorkspaceStore } from "@/renderer/features/workspace/stores/workspaceStore"; import { useSidebarData } from "../hooks/useSidebarData"; import { useSidebarStore } from "../stores/sidebarStore"; +import { useTaskViewedStore } from "../stores/taskViewedStore"; import { HomeItem } from "./items/HomeItem"; import { NewTaskItem } from "./items/NewTaskItem"; -import { ProjectsItem } from "./items/ProjectsItem"; import { TaskItem } from "./items/TaskItem"; -import { ViewItem } from "./items/ViewItem"; -import { SidebarSection } from "./SidebarSection"; +import { SortableFolderSection } from "./SortableFolderSection"; function SidebarMenuComponent() { - const { view, navigateToTaskList, navigateToTask, navigateToTaskInput } = - useNavigationStore(); + const { view, navigateToTask, navigateToTaskInput } = useNavigationStore(); const activeFilters = useTaskStore((state) => state.activeFilters); - const setActiveFilters = useTaskStore((state) => state.setActiveFilters); const { data: currentUser } = useMeQuery(); const { data: allTasks = [] } = useTasks(); const { folders, removeFolder } = useRegisteredFoldersStore(); const collapsedSections = useSidebarStore((state) => state.collapsedSections); const toggleSection = useSidebarStore((state) => state.toggleSection); + const folderOrder = useSidebarStore((state) => state.folderOrder); + const reorderFolders = useSidebarStore((state) => state.reorderFolders); const workspaces = useWorkspaceStore.use.workspaces(); const taskStates = useTaskExecutionStore((state) => state.taskStates); + const markAsViewed = useTaskViewedStore((state) => state.markAsViewed); const { showContextMenu, renameTask, renameDialogOpen, setRenameDialogOpen } = useTaskContextMenu(); @@ -44,6 +46,35 @@ function SidebarMenuComponent() { currentUser, }); + const handleDragOver: DragDropEvents["dragover"] = useCallback( + (event) => { + const source = event.operation.source; + const target = event.operation.target; + + // type is at sortable level, not in data + if (source?.type !== "folder" || target?.type !== "folder") { + return; + } + + const sourceId = source?.id; + const targetId = target?.id; + + if (!sourceId || !targetId || sourceId === targetId) return; + + const sourceIndex = folderOrder.indexOf(String(sourceId)); + const targetIndex = folderOrder.indexOf(String(targetId)); + + if ( + sourceIndex !== -1 && + targetIndex !== -1 && + sourceIndex !== targetIndex + ) { + reorderFolders(sourceIndex, targetIndex); + } + }, + [folderOrder, reorderFolders], + ); + const taskMap = new Map(); for (const task of allTasks) { taskMap.set(task.id, task); @@ -53,21 +84,10 @@ function SidebarMenuComponent() { navigateToTaskInput(); }; - const handleViewClick = (filters: typeof activeFilters) => { - setActiveFilters(filters); - navigateToTaskList(); - }; - - const handleProjectClick = (repository: string) => { - const newFilters = { ...activeFilters }; - newFilters.repository = [{ value: repository, operator: "is" }]; - setActiveFilters(newFilters); - navigateToTaskList(); - }; - const handleTaskClick = (taskId: string) => { const task = taskMap.get(taskId); if (task) { + markAsViewed(taskId); navigateToTask(task); } }; @@ -135,59 +155,77 @@ function SidebarMenuComponent() { overflowX: "hidden", }} > - + - {sidebarData.views.map((view) => ( - handleViewClick(view.filters)} - /> - ))} - - - - {sidebarData.folders.map((folder, index) => ( - } - isExpanded={!collapsedSections.has(folder.id)} - onToggle={() => toggleSection(folder.id)} - addSpacingBefore={index === 0} - onContextMenu={(e) => handleFolderContextMenu(folder.id, e)} - > - handleFolderNewTask(folder.id)} /> - {folder.tasks.map((task) => ( - + {sidebarData.folders.map((folder, index) => { + const isExpanded = !collapsedSections.has(folder.id); + return ( + + ) : ( + + ) } - workspaceMode={taskStates[task.id]?.workspaceMode} - onClick={() => handleTaskClick(task.id)} - onContextMenu={(e) => handleTaskContextMenu(task.id, e)} - /> - ))} - - ))} + isExpanded={isExpanded} + onToggle={() => toggleSection(folder.id)} + onContextMenu={(e) => handleFolderContextMenu(folder.id, e)} + > + handleFolderNewTask(folder.id)} /> + {folder.tasks.map((task) => ( + handleTaskClick(task.id)} + onContextMenu={(e) => handleTaskContextMenu(task.id, e)} + /> + ))} + + ); + })} + + {(source) => + source?.type === "folder" ? ( +
+ + {source.data?.label} +
+ ) : null + } +
+
diff --git a/apps/array/src/renderer/features/sidebar/components/SidebarSection.tsx b/apps/array/src/renderer/features/sidebar/components/SidebarSection.tsx index 48774a44..8c2d1940 100644 --- a/apps/array/src/renderer/features/sidebar/components/SidebarSection.tsx +++ b/apps/array/src/renderer/features/sidebar/components/SidebarSection.tsx @@ -26,24 +26,28 @@ export function SidebarSection({ diff --git a/apps/array/src/renderer/features/sidebar/components/SortableFolderSection.tsx b/apps/array/src/renderer/features/sidebar/components/SortableFolderSection.tsx new file mode 100644 index 00000000..c6a143c3 --- /dev/null +++ b/apps/array/src/renderer/features/sidebar/components/SortableFolderSection.tsx @@ -0,0 +1,73 @@ +import { useSortable } from "@dnd-kit/react/sortable"; +import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react"; +import * as Collapsible from "@radix-ui/react-collapsible"; +import type { ReactNode } from "react"; + +interface SortableFolderSectionProps { + id: string; + index: number; + label: string; + icon: ReactNode; + isExpanded: boolean; + onToggle: () => void; + children: ReactNode; + onContextMenu?: (e: React.MouseEvent) => void; +} + +export function SortableFolderSection({ + id, + index, + label, + icon, + isExpanded, + onToggle, + children, + onContextMenu, +}: SortableFolderSectionProps) { + const { ref, handleRef, isDragging } = useSortable({ + id, + index, + type: "folder", + data: { label, icon }, + transition: { + duration: 200, + easing: "ease", + }, + }); + + return ( +
+ + + + + {children} + +
+ ); +} diff --git a/apps/array/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/array/src/renderer/features/sidebar/components/items/TaskItem.tsx index f71eb074..425dbb1e 100644 --- a/apps/array/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/array/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -1,12 +1,8 @@ -import { - CheckCircleIcon, - CircleIcon, - Cloud, - XCircleIcon, -} from "@phosphor-icons/react"; +import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; +import { Cloud, GitBranch as GitBranchIcon } from "@phosphor-icons/react"; +import { formatRelativeTime } from "@renderer/utils/time"; import type { WorkspaceMode } from "@shared/types"; import { useQuery } from "@tanstack/react-query"; -import type { TaskStatus } from "../../types"; import { SidebarItem } from "../SidebarItem"; function useCurrentBranch(repoPath?: string, worktreeName?: string) { @@ -22,38 +18,17 @@ function useCurrentBranch(repoPath?: string, worktreeName?: string) { interface TaskItemProps { id: string; label: string; - status: TaskStatus; isActive: boolean; worktreeName?: string; worktreePath?: string; workspaceMode?: WorkspaceMode; + lastActivityAt?: number; + isGenerating?: boolean; + isUnread?: boolean; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void; } -function getStatusIcon(status: TaskStatus) { - if (status === "in_progress" || status === "started") { - return ( - - ); - } - if (status === "completed") { - return ( - - ); - } - if (status === "failed") { - return ( - - ); - } - return ; -} - interface DiffStatsDisplayProps { worktreePath: string; } @@ -90,7 +65,7 @@ function DiffStatsDisplay({ worktreePath }: DiffStatsDisplayProps) { return ( {parts} @@ -100,18 +75,27 @@ function DiffStatsDisplay({ worktreePath }: DiffStatsDisplayProps) { export function TaskItem({ label, - status, isActive, worktreeName, worktreePath, workspaceMode, + lastActivityAt, + isGenerating, + isUnread, onClick, onContextMenu, }: TaskItemProps) { const { data: currentBranch } = useCurrentBranch(worktreePath, worktreeName); const isCloudTask = workspaceMode === "cloud"; - const subtitle = isCloudTask ? ( + + const activityText = isGenerating + ? "Generating..." + : lastActivityAt + ? formatRelativeTime(lastActivityAt) + : undefined; + + const baseSubtitle = isCloudTask ? ( Cloud @@ -120,10 +104,29 @@ export function TaskItem({ (worktreeName ?? currentBranch) ); + const subtitle = activityText ? ( + + {baseSubtitle && <>{baseSubtitle} · } + {activityText} + + ) : ( + baseSubtitle + ); + + const icon = isGenerating ? ( + + ) : isUnread ? ( + + ■ + + ) : ( + + ); + return ( new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ); -} +function sortFoldersByOrder( + folders: RegisteredFolder[], + order: string[], +): RegisteredFolder[] { + const folderMap = new Map(folders.map((f) => [f.id, f])); + const result: RegisteredFolder[] = []; -function sortByUpdatedAt(tasks: Task[]): Task[] { - return [...tasks].sort( - (a, b) => - new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + for (const id of order) { + const folder = folderMap.get(id); + if (folder) { + result.push(folder); + folderMap.delete(id); + } + } + // Add any remaining folders not in the order (sorted by createdAt as fallback) + const remaining = Array.from(folderMap.values()).sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); + return [...result, ...remaining]; } function createTaskViews(currentUser: Schemas.User | undefined): TaskView[] { @@ -165,6 +181,11 @@ export function useSidebarData({ const { data: allTasks = [], isLoading } = useTasks(); const { folders } = useRegisteredFoldersStore(); const workspaces = useWorkspaceStore.use.workspaces(); + const sessions = useSessionStore((state) => state.sessions); + const lastViewedAt = useTaskViewedStore((state) => state.lastViewedAt); + const localActivityAt = useTaskViewedStore((state) => state.lastActivityAt); + const folderOrder = useSidebarStore((state) => state.folderOrder); + const syncFolderOrder = useSidebarStore((state) => state.syncFolderOrder); const userName = currentUser?.first_name || currentUser?.email || "Account"; const isHomeActive = activeView.type === "task-input"; @@ -175,7 +196,14 @@ export function useSidebarData({ const repositories = buildRepositoryMap(allTasks); const activeRepository = getActiveRepository(activeFilters); - const sortedFolders = sortByCreatedAt(folders); + // Sync folder order when folders change + const folderIds = folders.map((f) => f.id); + useEffect(() => { + syncFolderOrder(folderIds); + }, [syncFolderOrder, folderIds]); + + // Sort folders by persisted order + const sortedFolders = sortFoldersByOrder(folders, folderOrder); const tasksByFolder = groupTasksByFolder(allTasks, folders, workspaces); const activeTaskId = @@ -183,19 +211,51 @@ export function useSidebarData({ ? activeView.data.id : null; + const getSessionForTask = (taskId: string): AgentSession | undefined => { + return Object.values(sessions).find((s) => s.taskId === taskId); + }; + const folderData: FolderData[] = sortedFolders.map((folder) => { const folderTasks = tasksByFolder.get(folder.id) || []; - const sortedTasks = sortByUpdatedAt(folderTasks); + + const tasksWithActivity = folderTasks.map((task) => { + const session = getSessionForTask(task.id); + // Use max of task.updated_at and local activity timestamp for accurate ordering + const apiUpdatedAt = new Date(task.updated_at).getTime(); + const localActivity = localActivityAt[task.id]; + const lastActivityAt = localActivity + ? Math.max(apiUpdatedAt, localActivity) + : apiUpdatedAt; + return { + task, + lastActivityAt, + isGenerating: session?.isPromptPending ?? false, + }; + }); + + tasksWithActivity.sort((a, b) => b.lastActivityAt - a.lastActivityAt); return { id: folder.id, name: folder.name, path: folder.path, - tasks: sortedTasks.map((task) => ({ - id: task.id, - title: task.title, - status: (task.latest_run?.status || "pending") as TaskStatus, - })), + tasks: tasksWithActivity.map(({ task, lastActivityAt, isGenerating }) => { + const taskLastViewedAt = lastViewedAt[task.id]; + const isCurrentlyViewing = activeTaskId === task.id; + // Only show unread if: user has viewed it before AND there's new activity since + const isUnread = + !isCurrentlyViewing && + taskLastViewedAt !== undefined && + lastActivityAt > taskLastViewedAt; + + return { + id: task.id, + title: task.title, + lastActivityAt, + isGenerating, + isUnread, + }; + }), }; }); diff --git a/apps/array/src/renderer/features/sidebar/stores/sidebarStore.ts b/apps/array/src/renderer/features/sidebar/stores/sidebarStore.ts index 921084a6..a6fd7312 100644 --- a/apps/array/src/renderer/features/sidebar/stores/sidebarStore.ts +++ b/apps/array/src/renderer/features/sidebar/stores/sidebarStore.ts @@ -6,6 +6,7 @@ interface SidebarStoreState { width: number; isResizing: boolean; collapsedSections: Set; + folderOrder: string[]; } interface SidebarStoreActions { @@ -13,6 +14,9 @@ interface SidebarStoreActions { setWidth: (width: number) => void; setIsResizing: (isResizing: boolean) => void; toggleSection: (sectionId: string) => void; + reorderFolders: (fromIndex: number, toIndex: number) => void; + setFolderOrder: (order: string[]) => void; + syncFolderOrder: (folderIds: string[]) => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; @@ -24,6 +28,7 @@ export const useSidebarStore = create()( width: 256, isResizing: false, collapsedSections: new Set(), + folderOrder: [], setOpen: (open) => set({ open }), setWidth: (width) => set({ width }), setIsResizing: (isResizing) => set({ isResizing }), @@ -37,6 +42,30 @@ export const useSidebarStore = create()( } return { collapsedSections: newCollapsedSections }; }), + reorderFolders: (fromIndex, toIndex) => + set((state) => { + const newOrder = [...state.folderOrder]; + const [removed] = newOrder.splice(fromIndex, 1); + newOrder.splice(toIndex, 0, removed); + return { folderOrder: newOrder }; + }), + setFolderOrder: (order) => set({ folderOrder: order }), + syncFolderOrder: (folderIds) => + set((state) => { + const existingOrder = state.folderOrder.filter((id) => + folderIds.includes(id), + ); + const newFolders = folderIds.filter( + (id) => !state.folderOrder.includes(id), + ); + if ( + newFolders.length > 0 || + existingOrder.length !== state.folderOrder.length + ) { + return { folderOrder: [...existingOrder, ...newFolders] }; + } + return state; + }), }), { name: "sidebar-storage", @@ -44,18 +73,21 @@ export const useSidebarStore = create()( open: state.open, width: state.width, collapsedSections: Array.from(state.collapsedSections), + folderOrder: state.folderOrder, }), merge: (persisted, current) => { const persistedState = persisted as { open?: boolean; width?: number; collapsedSections?: string[]; + folderOrder?: string[]; }; return { ...current, open: persistedState.open ?? current.open, width: persistedState.width ?? current.width, collapsedSections: new Set(persistedState.collapsedSections ?? []), + folderOrder: persistedState.folderOrder ?? [], }; }, }, diff --git a/apps/array/src/renderer/features/sidebar/stores/taskViewedStore.ts b/apps/array/src/renderer/features/sidebar/stores/taskViewedStore.ts new file mode 100644 index 00000000..369e0322 --- /dev/null +++ b/apps/array/src/renderer/features/sidebar/stores/taskViewedStore.ts @@ -0,0 +1,60 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface TaskViewedState { + lastViewedAt: Record; + lastActivityAt: Record; +} + +interface TaskViewedActions { + markAsViewed: (taskId: string) => void; + getLastViewedAt: (taskId: string) => number | undefined; + markActivity: (taskId: string) => void; + getLastActivityAt: (taskId: string) => number | undefined; +} + +type TaskViewedStore = TaskViewedState & TaskViewedActions; + +export const useTaskViewedStore = create()( + persist( + (set, get) => ({ + lastViewedAt: {}, + lastActivityAt: {}, + + markAsViewed: (taskId: string) => { + set((state) => ({ + lastViewedAt: { + ...state.lastViewedAt, + [taskId]: Date.now(), + }, + })); + }, + + getLastViewedAt: (taskId: string) => { + return get().lastViewedAt[taskId]; + }, + + markActivity: (taskId: string) => { + set((state) => { + const currentViewed = state.lastViewedAt[taskId] || 0; + const now = Date.now(); + // Ensure activity timestamp is always after last viewed time + const activityTime = Math.max(now, currentViewed + 1); + return { + lastActivityAt: { + ...state.lastActivityAt, + [taskId]: activityTime, + }, + }; + }); + }, + + getLastActivityAt: (taskId: string) => { + return get().lastActivityAt[taskId]; + }, + }), + { + name: "task-viewed-storage", + }, + ), +); diff --git a/apps/array/src/renderer/features/task-detail/components/TaskLogsPanel.tsx b/apps/array/src/renderer/features/task-detail/components/TaskLogsPanel.tsx index 8a0ad42a..fef22120 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskLogsPanel.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskLogsPanel.tsx @@ -1,6 +1,7 @@ import { BackgroundWrapper } from "@components/BackgroundWrapper"; import { LogView } from "@features/logs/components/LogView"; import { useSessionStore } from "@features/sessions/stores/sessionStore"; +import { useTaskViewedStore } from "@features/sidebar/stores/taskViewedStore"; import { useTaskData } from "@features/task-detail/hooks/useTaskData"; import { selectWorktreePath, @@ -28,6 +29,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) { const connectToTask = useSessionStore((state) => state.connectToTask); const sendPrompt = useSessionStore((state) => state.sendPrompt); const cancelPrompt = useSessionStore((state) => state.cancelPrompt); + const markActivity = useTaskViewedStore((state) => state.markActivity); const isRunning = session?.status === "connected" || session?.status === "connecting"; @@ -42,16 +44,20 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) { hasAttemptedConnect.current = true; const isNewSession = !task.latest_run?.id; + const hasInitialPrompt = isNewSession && task.description; + + if (hasInitialPrompt) { + markActivity(task.id); + } connectToTask({ taskId: task.id, repoPath, latestRunId: task.latest_run?.id, latestRunLogUrl: task.latest_run?.log_url, - initialPrompt: - isNewSession && task.description - ? [{ type: "text", text: task.description }] - : undefined, + initialPrompt: hasInitialPrompt + ? [{ type: "text", text: task.description }] + : undefined, }); }, [ task.id, @@ -60,18 +66,20 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) { repoPath, session, connectToTask, + markActivity, ]); const handleSendPrompt = useCallback( async (text: string) => { try { + markActivity(taskId); const result = await sendPrompt(taskId, text); log.info("Prompt completed", { stopReason: result.stopReason }); } catch (error) { log.error("Failed to send prompt", error); } }, - [taskId, sendPrompt], + [taskId, sendPrompt, markActivity], ); const handleCancelPrompt = useCallback(async () => { diff --git a/apps/array/src/renderer/utils/time.ts b/apps/array/src/renderer/utils/time.ts index e3eafeb3..8f00ae64 100644 --- a/apps/array/src/renderer/utils/time.ts +++ b/apps/array/src/renderer/utils/time.ts @@ -13,3 +13,19 @@ export function formatDuration(seconds: number): string { const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, "0")}`; } + +export function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diffMs = now - timestamp; + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return `${diffDays}d ago`; + return `${Math.floor(diffDays / 7)}w ago`; +}