+
- {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`;
+}