557 lines
19 KiB
TypeScript
557 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { usePathname, useRouter } from "@/lib/routing/client-router";
|
|
import { linkToTask, replaceTaskUrl } from "@/lib/links";
|
|
import type { Repository, TaskSessionState } from "@/lib/types/http";
|
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
|
import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher";
|
|
import { buildTaskSwitcherProps } from "./task-session-sidebar-switcher-props";
|
|
import { SidebarFilterBar } from "./sidebar-filter/sidebar-filter-bar";
|
|
import { MOCK_ITEMS, MOCK_SIDEBAR } from "./sidebar-mock-data";
|
|
import { SidebarDialogs } from "./task-session-sidebar-dialogs";
|
|
import { PanelRoot } from "./panel-primitives";
|
|
import { TaskSidebarScrollArea } from "./task-sidebar-scroll-area";
|
|
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
|
|
import { useTaskActions, type TaskActionOptions } from "@/hooks/use-task-actions";
|
|
import { useTaskMenuActions } from "@/hooks/use-task-menu-actions";
|
|
import { useTaskDetachDialog } from "@/hooks/use-detach-task";
|
|
import { useNestTaskByDrag } from "@/hooks/use-nest-task";
|
|
import { useSidebarSelection, SidebarBulkDialogs } from "./task-session-sidebar-selection";
|
|
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
|
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
|
import { repositorySlug } from "@/lib/repository-slug";
|
|
import {
|
|
buildSwitchToSession,
|
|
effectiveTaskPendingAction,
|
|
selectTaskWithLayout,
|
|
} from "./task-select-helpers";
|
|
import { useArchivedTaskState } from "./task-archived-context";
|
|
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
|
|
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
|
|
import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view";
|
|
import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
|
|
import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item";
|
|
import { useSidebarTaskLinking } from "./task-session-sidebar-task-linking";
|
|
import { buildSidebarItem } from "./task-session-sidebar-item";
|
|
import { useSidebarTaskEdit } from "./task-session-sidebar-edit";
|
|
import { TaskMoveErrorBanner } from "./task-move-error-banner";
|
|
import { useMoveToStep } from "./task-session-sidebar-move";
|
|
|
|
type TaskSessionSidebarProps = {
|
|
workspaceId: string | null;
|
|
workflowId: string | null;
|
|
/** Hide the embedded filter bar when the host surface (e.g. AppSidebar) renders its own. */
|
|
hideFilterBar?: boolean;
|
|
};
|
|
|
|
function findSidebarTask(state: ReturnType<StoreApi["getState"]>, taskId: string) {
|
|
const activeTask = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
|
if (activeTask) return activeTask;
|
|
for (const tasks of Object.values(state.sidebarArchivedTasks?.itemsByWorkspaceId ?? {})) {
|
|
const archivedTask = tasks.find((task) => task.id === taskId);
|
|
if (archivedTask) return archivedTask;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function useSidebarData(workspaceId: string | null) {
|
|
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const sessionsById = useAppStore((state) => state.taskSessions.items);
|
|
const acknowledgedAgentErrors = useAppStore((state) => state.acknowledgedAgentErrors);
|
|
const dismissedAgentErrors = useAppStore((state) => state.dismissedAgentErrors);
|
|
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
|
const automaticColorSettings = useAppStore(
|
|
(state) => state.userSettings.sidebarTaskColorAutomation,
|
|
);
|
|
const archivedState = useArchivedTaskState();
|
|
|
|
const selectedTaskId = useMemo(() => {
|
|
if (activeSessionId) return sessionsById[activeSessionId]?.task_id ?? activeTaskId;
|
|
return activeTaskId;
|
|
}, [activeSessionId, activeTaskId, sessionsById]);
|
|
|
|
const {
|
|
allTasks,
|
|
pendingArchiveTaskIds,
|
|
allSteps,
|
|
stepsByWorkflowId,
|
|
wipQueueByTaskId,
|
|
workflows,
|
|
isLoading: isLoadingWorkflow,
|
|
archivedError,
|
|
retryArchivedTasks,
|
|
workspaceContextError,
|
|
workspaceContextPending,
|
|
workspaceContextAccessDenied,
|
|
retryWorkspaceContext,
|
|
} = useWorkspaceSidebarTasks(workspaceId);
|
|
|
|
const tasksWithRepositories = useMemo(() => {
|
|
const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : [];
|
|
const repositorySlugById = new Map(
|
|
repositories.map((repo: Repository) => [repo.id, repositorySlug(repo)]),
|
|
);
|
|
const repositoriesById = new Map(
|
|
Object.values(repositoriesByWorkspace)
|
|
.flat()
|
|
.map((repo: Repository) => [repo.id, repo]),
|
|
);
|
|
const stepColorById = new Map(allSteps.map((step) => [step.id, step.color]));
|
|
const titleById = new Map(allTasks.map((t) => [t.id, t.title]));
|
|
const workflowNameById = new Map(workflows.map((w) => [w.id, w.name]));
|
|
const stepTitleById = new Map(allSteps.map((s) => [s.id, s.title]));
|
|
const mapCtx = {
|
|
repositorySlugById,
|
|
titleById,
|
|
workflowNameById,
|
|
stepTitleById,
|
|
wipQueueByTaskId,
|
|
acknowledgedAgentErrors,
|
|
dismissedAgentErrors,
|
|
workspaceId: workspaceId ?? undefined,
|
|
repositoriesById,
|
|
stepColorById,
|
|
automaticColorSettings,
|
|
pendingArchiveTaskIds,
|
|
};
|
|
const items: TaskSwitcherItem[] = allTasks.map((task) => buildSidebarItem(task, mapCtx));
|
|
if (
|
|
archivedState.isArchived &&
|
|
archivedState.archivedTaskId &&
|
|
!items.some((t) => t.id === archivedState.archivedTaskId)
|
|
) {
|
|
items.unshift(buildArchivedSidebarItem(archivedState, mapCtx));
|
|
}
|
|
return items;
|
|
}, [
|
|
repositoriesByWorkspace,
|
|
allTasks,
|
|
allSteps,
|
|
workflows,
|
|
workspaceId,
|
|
archivedState,
|
|
wipQueueByTaskId,
|
|
acknowledgedAgentErrors,
|
|
dismissedAgentErrors,
|
|
automaticColorSettings,
|
|
pendingArchiveTaskIds,
|
|
]);
|
|
|
|
return {
|
|
activeTaskId,
|
|
selectedTaskId,
|
|
allSteps,
|
|
stepsByWorkflowId,
|
|
isLoadingWorkflow,
|
|
archivedError,
|
|
retryArchivedTasks,
|
|
workspaceContextError,
|
|
workspaceContextPending,
|
|
workspaceContextAccessDenied,
|
|
retryWorkspaceContext,
|
|
tasksWithRepositories,
|
|
workflows,
|
|
};
|
|
}
|
|
|
|
type StoreApi = ReturnType<typeof useAppStoreApi>;
|
|
|
|
function useArchiveActions(store: StoreApi) {
|
|
const { t } = useTranslation();
|
|
const { runArchive: archiveAndSwitch } = useTaskMenuActions({ useLayoutSwitch: true });
|
|
const [archivingTask, setArchivingTask] = useState<{
|
|
id: string;
|
|
title: string;
|
|
executorType?: string | null;
|
|
} | null>(null);
|
|
const [archivingTaskId, setArchivingTaskId] = useState<string | null>(null);
|
|
const [isArchiving, setIsArchiving] = useState(false);
|
|
|
|
const runArchive = useCallback(
|
|
async (taskId: string, opts: TaskActionOptions) => {
|
|
setIsArchiving(true);
|
|
setArchivingTaskId(taskId);
|
|
try {
|
|
await archiveAndSwitch(taskId, opts);
|
|
} catch (error) {
|
|
console.error("Failed to archive task:", error);
|
|
} finally {
|
|
setIsArchiving(false);
|
|
setArchivingTaskId((current) => (current === taskId ? null : current));
|
|
setArchivingTask((current) => (current?.id === taskId ? null : current));
|
|
}
|
|
},
|
|
[archiveAndSwitch],
|
|
);
|
|
|
|
const handleArchiveTask = useCallback(
|
|
(taskId: string, opts?: TaskActionOptions) => {
|
|
if (opts) {
|
|
void runArchive(taskId, opts);
|
|
return;
|
|
}
|
|
const state = store.getState();
|
|
const task = findSidebarTask(state, taskId);
|
|
setArchivingTask({
|
|
id: taskId,
|
|
title: task?.title ?? t("task:thisTask"),
|
|
executorType: task?.primaryExecutorType,
|
|
});
|
|
},
|
|
[runArchive, store, t],
|
|
);
|
|
|
|
const handleArchiveConfirm = useCallback(
|
|
async (opts: { cascade: boolean }) => {
|
|
if (!archivingTask) return;
|
|
await runArchive(archivingTask.id, opts);
|
|
},
|
|
[archivingTask, runArchive],
|
|
);
|
|
|
|
return {
|
|
archivingTask,
|
|
setArchivingTask,
|
|
archivingTaskId,
|
|
isArchiving,
|
|
handleArchiveTask,
|
|
handleArchiveConfirm,
|
|
};
|
|
}
|
|
|
|
function useDeleteActions(store: StoreApi) {
|
|
const { t } = useTranslation();
|
|
const { runDelete } = useTaskMenuActions({ useLayoutSwitch: true });
|
|
const [deletingTask, setDeletingTask] = useState<{
|
|
id: string;
|
|
title: string;
|
|
executorType?: string | null;
|
|
} | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
const handleDeleteTask = useCallback(
|
|
(taskId: string) => {
|
|
const state = store.getState();
|
|
const task = findSidebarTask(state, taskId);
|
|
setDeletingTask({
|
|
id: taskId,
|
|
title: task?.title ?? t("task:thisTask"),
|
|
executorType: task?.primaryExecutorType,
|
|
});
|
|
},
|
|
[store],
|
|
);
|
|
|
|
const handleDeleteConfirm = useCallback(
|
|
async (opts: TaskActionOptions & { cascade: boolean; discardWorktreeChanges: boolean }) => {
|
|
if (!deletingTask || isDeleting) return;
|
|
const taskId = deletingTask.id;
|
|
setIsDeleting(true);
|
|
try {
|
|
await runDelete(taskId, opts);
|
|
} catch (error) {
|
|
console.error("Failed to delete task:", error);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
setDeletingTask(null);
|
|
}
|
|
},
|
|
[deletingTask, isDeleting, runDelete],
|
|
);
|
|
|
|
const deletingTaskId = isDeleting ? (deletingTask?.id ?? null) : null;
|
|
|
|
return {
|
|
deletingTask,
|
|
setDeletingTask,
|
|
deletingTaskId,
|
|
isDeleting,
|
|
handleDeleteTask,
|
|
handleDeleteConfirm,
|
|
};
|
|
}
|
|
|
|
function useSidebarTaskSelection(params: {
|
|
store: StoreApi;
|
|
pathname: string | null;
|
|
router: ReturnType<typeof useRouter>;
|
|
loadTaskSessionsForTask: ReturnType<typeof useTaskRemoval>["loadTaskSessionsForTask"];
|
|
setActiveSession: (taskId: string, sessionId: string) => void;
|
|
setActiveTask: (taskId: string) => void;
|
|
setPreparingTaskId: (taskId: string | null) => void;
|
|
}) {
|
|
const {
|
|
store,
|
|
pathname,
|
|
router,
|
|
loadTaskSessionsForTask,
|
|
setActiveSession,
|
|
setActiveTask,
|
|
setPreparingTaskId,
|
|
} = params;
|
|
const switchToSession = useMemo(
|
|
() => buildSwitchToSession(store, setActiveSession),
|
|
[store, setActiveSession],
|
|
);
|
|
const selectionControllerRef = useRef<AbortController | null>(null);
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
selectionControllerRef.current = controller;
|
|
return () => {
|
|
controller.abort();
|
|
if (selectionControllerRef.current === controller) selectionControllerRef.current = null;
|
|
};
|
|
}, [pathname]);
|
|
return useCallback(
|
|
(taskId: string) => {
|
|
const state = store.getState();
|
|
const task = findSidebarTask(state, taskId);
|
|
const onTaskRoute =
|
|
!!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));
|
|
if (!onTaskRoute && (!effectiveTaskPendingAction(task) || task?.isArchived)) {
|
|
setActiveTask(taskId);
|
|
router.push(linkToTask(taskId));
|
|
return;
|
|
}
|
|
if (task?.isArchived) {
|
|
setActiveTask(taskId);
|
|
replaceTaskUrl(taskId);
|
|
return;
|
|
}
|
|
selectTaskWithLayout({
|
|
taskId,
|
|
task: task ?? undefined,
|
|
store,
|
|
switchToSession: onTaskRoute
|
|
? switchToSession
|
|
: (selectedTaskId, sessionId) => setActiveSession(selectedTaskId, sessionId),
|
|
loadTaskSessionsForTask,
|
|
setActiveTask,
|
|
setPreparingTaskId,
|
|
navigateToTask: onTaskRoute
|
|
? replaceTaskUrl
|
|
: (selectedTaskId) => router.push(linkToTask(selectedTaskId)),
|
|
selectionSignal: selectionControllerRef.current?.signal,
|
|
});
|
|
},
|
|
[
|
|
loadTaskSessionsForTask,
|
|
pathname,
|
|
router,
|
|
setActiveSession,
|
|
setActiveTask,
|
|
setPreparingTaskId,
|
|
store,
|
|
switchToSession,
|
|
],
|
|
);
|
|
}
|
|
|
|
export function useSidebarActions(store: StoreApi) {
|
|
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
|
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
|
const [preparingTaskId, setPreparingTaskId] = useState<string | null>(null);
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const { loadTaskSessionsForTask } = useTaskRemoval({
|
|
store,
|
|
useLayoutSwitch: true,
|
|
});
|
|
|
|
const handleSelectTask = useSidebarTaskSelection({
|
|
store,
|
|
pathname,
|
|
router,
|
|
loadTaskSessionsForTask,
|
|
setActiveSession,
|
|
setActiveTask,
|
|
setPreparingTaskId,
|
|
});
|
|
|
|
return { preparingTaskId, handleSelectTask, ...useTaskRowActions(store) };
|
|
}
|
|
|
|
export function useTaskRowActions(store: StoreApi) {
|
|
const { renameTaskById } = useTaskActions();
|
|
const archiveActions = useArchiveActions(store);
|
|
const deleteActions = useDeleteActions(store);
|
|
const detachActions = useTaskDetachDialog(store);
|
|
const handleNestTask = useNestTaskByDrag();
|
|
const linkActions = useSidebarLinkActions(store);
|
|
const editActions = useSidebarTaskEdit();
|
|
|
|
const [renamingTask, setRenamingTask] = useState<{ id: string; title: string } | null>(null);
|
|
const [creatingSubtask, setCreatingSubtask] = useState<{ id: string; title: string } | null>(
|
|
null,
|
|
);
|
|
const [taskMoveError, setTaskMoveError] = useState<unknown>(null);
|
|
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
useEffect(() => {
|
|
setTaskMoveError(null);
|
|
}, [activeTaskId]);
|
|
|
|
const handleRenameTask = useCallback((taskId: string, currentTitle: string) => {
|
|
setRenamingTask({ id: taskId, title: currentTitle });
|
|
}, []);
|
|
|
|
const handleCreateSubtask = useCallback((taskId: string, taskTitle: string) => {
|
|
setCreatingSubtask({ id: taskId, title: taskTitle });
|
|
}, []);
|
|
|
|
const handleRenameSubmit = useCallback(
|
|
async (newTitle: string) => {
|
|
if (!renamingTask) return;
|
|
try {
|
|
await renameTaskById(renamingTask.id, newTitle);
|
|
} catch (error) {
|
|
console.error("Failed to rename task:", error);
|
|
}
|
|
setRenamingTask(null);
|
|
},
|
|
[renamingTask, renameTaskById],
|
|
);
|
|
|
|
const clearTaskMoveError = useCallback(() => setTaskMoveError(null), []);
|
|
const reportTaskMoveError = useCallback((error: unknown) => setTaskMoveError(error), []);
|
|
const handleMoveToStep = useMoveToStep(store, clearTaskMoveError, reportTaskMoveError);
|
|
|
|
return {
|
|
taskMoveError,
|
|
handleMoveToStep,
|
|
handleNestTask,
|
|
renamingTask,
|
|
setRenamingTask,
|
|
handleRenameTask,
|
|
handleRenameSubmit,
|
|
creatingSubtask,
|
|
setCreatingSubtask,
|
|
handleCreateSubtask,
|
|
...linkActions,
|
|
...archiveActions,
|
|
...deleteActions,
|
|
...detachActions,
|
|
...editActions,
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line max-lines-per-function -- desktop sidebar wiring must preserve one shared task surface
|
|
export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
|
workspaceId,
|
|
hideFilterBar,
|
|
}: TaskSessionSidebarProps) {
|
|
const store = useAppStoreApi();
|
|
const { t } = useTranslation();
|
|
useRepositories(workspaceId);
|
|
useWorkspaceMRs(workspaceId);
|
|
const pathname = usePathname();
|
|
|
|
const {
|
|
activeTaskId,
|
|
selectedTaskId,
|
|
stepsByWorkflowId,
|
|
workflows,
|
|
isLoadingWorkflow,
|
|
archivedError,
|
|
retryArchivedTasks,
|
|
workspaceContextError,
|
|
workspaceContextPending,
|
|
workspaceContextAccessDenied,
|
|
retryWorkspaceContext,
|
|
tasksWithRepositories,
|
|
} = useSidebarData(workspaceId);
|
|
|
|
// Only highlight while viewing a task route; AppSidebar is global and activeTaskId lingers.
|
|
const onTaskRoute =
|
|
!!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));
|
|
const highlightedTaskId = onTaskRoute ? activeTaskId : null;
|
|
const highlightedSelectedTaskId = onTaskRoute ? selectedTaskId : null;
|
|
|
|
const sidebarActions = useSidebarActions(store);
|
|
const { preparingTaskId, taskMoveError } = sidebarActions;
|
|
const taskLinkHandlers = useSidebarTaskLinking(workspaceId, sidebarActions);
|
|
const repositories =
|
|
useAppStore((state) =>
|
|
workspaceId ? state.repositories.itemsByWorkspaceId[workspaceId] : undefined,
|
|
) ?? [];
|
|
|
|
const displayTasks = useMemo(() => {
|
|
if (workspaceContextAccessDenied) return [];
|
|
if (MOCK_SIDEBAR) return MOCK_ITEMS;
|
|
return preparingTaskId
|
|
? tasksWithRepositories.map((t) =>
|
|
t.id === preparingTaskId ? { ...t, sessionState: "STARTING" as TaskSessionState } : t,
|
|
)
|
|
: tasksWithRepositories;
|
|
}, [tasksWithRepositories, preparingTaskId, workspaceContextAccessDenied]);
|
|
|
|
const toggleSidebarGroupCollapsed = useAppStore((state) => state.toggleSidebarGroupCollapsed);
|
|
const collapsedSubtaskParents = useAppStore((state) => state.collapsedSubtaskParents);
|
|
const toggleSubtaskCollapsed = useAppStore((state) => state.toggleSubtaskCollapsed);
|
|
const { grouped, effectiveView, prefs } = useGroupedSidebarView(displayTasks);
|
|
const { pinnedTaskIds, togglePinnedTask, handleReorderGroup, handleReorderSubtasks } = prefs;
|
|
const selection = useSidebarSelection({
|
|
workspaceId,
|
|
grouped,
|
|
collapsedGroups: effectiveView.collapsedGroups,
|
|
collapsedSubtaskParents,
|
|
displayTasks,
|
|
});
|
|
const handleToggleGroup = useCallback(
|
|
(groupKey: string) => toggleSidebarGroupCollapsed(effectiveView.id, groupKey),
|
|
[toggleSidebarGroupCollapsed, effectiveView.id],
|
|
);
|
|
const switcherProps = buildTaskSwitcherProps({
|
|
grouped,
|
|
nestHierarchyTasks: displayTasks,
|
|
workflows,
|
|
stepsByWorkflowId,
|
|
highlightedTaskId,
|
|
highlightedSelectedTaskId,
|
|
effectiveView,
|
|
handleToggleGroup,
|
|
collapsedSubtaskParents,
|
|
toggleSubtaskCollapsed,
|
|
sidebarActions,
|
|
taskLinkHandlers,
|
|
pinnedTaskIds,
|
|
togglePinnedTask,
|
|
handleReorderGroup,
|
|
handleReorderSubtasks,
|
|
handleNestTask: sidebarActions.handleNestTask,
|
|
isLoadingWorkflow,
|
|
archivedError,
|
|
retryArchivedTasks,
|
|
archivedLoadErrorLabel: t("sidebar:archivedLoadFailed"),
|
|
archivedRetryLabel: t("sidebar:retry"),
|
|
workspaceContextError,
|
|
workspaceContextPending,
|
|
workspaceContextAccessDenied,
|
|
workspaceContextLoadErrorLabel: t("sidebar:workspaceContextRefreshFailed"),
|
|
workspaceContextAccessDeniedLabel: t("sidebar:workspaceContextAccessDenied"),
|
|
retryWorkspaceContext,
|
|
totalTaskCount: displayTasks.length,
|
|
selection,
|
|
});
|
|
return (
|
|
<PanelRoot data-testid="task-sidebar">
|
|
{!hideFilterBar && <SidebarFilterBar />}
|
|
{taskMoveError !== null && <TaskMoveErrorBanner error={taskMoveError} />}
|
|
<TaskSidebarScrollArea>
|
|
<TaskSwitcher {...switcherProps} />
|
|
<PluginSlot name="task-sidebar" />
|
|
</TaskSidebarScrollArea>
|
|
<SidebarDialogs
|
|
actions={sidebarActions}
|
|
repositories={repositories}
|
|
workspaceId={workspaceId}
|
|
stepsByWorkflowId={stepsByWorkflowId}
|
|
/>
|
|
<SidebarBulkDialogs selection={selection} />
|
|
</PanelRoot>
|
|
);
|
|
});
|