Files
kandev/apps/web/components/task/mobile/session-task-switcher-sheet.tsx
T
2026-04-17 23:20:10 +01:00

482 lines
16 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState, memo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Button } from "@kandev/ui/button";
import { TaskSwitcher } from "../task-switcher";
import { WorkspaceSwitcher } from "../workspace-switcher";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { replaceTaskUrl } from "@/lib/links";
import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api";
import { launchSession } from "@/lib/services/session-launch-service";
import { buildPrepareRequest } from "@/lib/services/session-launch-helpers";
import { useTasks } from "@/hooks/use-tasks";
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
import { useTaskRemoval } from "@/hooks/use-task-removal";
import { getSessionInfoForTask } from "@/lib/utils/session-info";
import type {
TaskState,
TaskSessionState,
Workspace,
Repository,
Task,
WorkflowSnapshot,
} from "@/lib/types/http";
import type { KanbanState } from "@/lib/state/slices";
type SessionTaskSwitcherSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string | null;
workflowId: string | null;
};
// Helper to map workflow snapshot to kanban state
function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: string) {
return {
workflowId: newWorkflowId,
isLoading: false,
steps: snapshot.steps.map((step) => ({
id: step.id,
title: step.name,
color: step.color,
position: step.position,
events: step.events,
})),
tasks: snapshot.tasks.map((task) => ({
id: task.id,
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description ?? undefined,
position: task.position ?? 0,
state: task.state,
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
primarySessionId: task.primary_session_id ?? undefined,
primarySessionState: task.primary_session_state ?? undefined,
sessionCount: task.session_count ?? undefined,
reviewStatus: task.review_status ?? undefined,
primaryExecutorId: task.primary_executor_id ?? undefined,
primaryExecutorType: task.primary_executor_type ?? undefined,
primaryExecutorName: task.primary_executor_name ?? undefined,
isRemoteExecutor: task.is_remote_executor ?? false,
updatedAt: task.updated_at,
})),
};
}
// Helper to sort by updated_at descending
function sortByUpdatedAtDesc<T extends { updated_at?: string | null }>(items: T[]): T[] {
return [...items].sort((a, b) => {
const aDate = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const bDate = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return bDate - aDate;
});
}
function useSheetData(workspaceId: string | null, workflowId: string | null) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const sessionsById = useAppStore((state) => state.taskSessions.items);
const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId);
const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId);
const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId);
const { tasks } = useTasks(workflowId);
const steps = useAppStore((state) => state.kanban.steps);
const workspaces = useAppStore((state) => state.workspaces.items);
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false);
const selectedTaskId = useMemo(() => {
if (activeSessionId) return sessionsById[activeSessionId]?.task_id ?? activeTaskId;
return activeTaskId;
}, [activeSessionId, activeTaskId, sessionsById]);
const tasksWithRepositories = useMemo(() => {
const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : [];
const repositoryPathsById = new Map(
repositories.map((repo: Repository) => [repo.id, repo.local_path]),
);
return tasks.map((task: KanbanState["tasks"][number]) => {
const sessionInfo = getSessionInfoForTask(
task.id,
sessionsByTaskId,
gitStatusByEnvId,
envIdBySessionId,
);
return {
id: task.id,
title: task.title,
state: task.state as TaskState | undefined,
sessionState:
sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined),
description: task.description,
workflowStepId: task.workflowStepId,
repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined,
diffStats: sessionInfo.diffStats,
updatedAt: sessionInfo.updatedAt ?? task.updatedAt,
isRemoteExecutor: task.isRemoteExecutor,
remoteExecutorType: task.primaryExecutorType ?? undefined,
remoteExecutorName: task.primaryExecutorName ?? undefined,
primarySessionId: task.primarySessionId ?? null,
};
});
}, [
repositoriesByWorkspace,
tasks,
workspaceId,
sessionsByTaskId,
gitStatusByEnvId,
envIdBySessionId,
]);
const dialogSteps = useMemo(
() =>
steps.map((step: KanbanState["steps"][number]) => ({
id: step.id,
title: step.title,
color: step.color,
events: step.events,
})),
[steps],
);
return {
activeTaskId,
selectedTaskId,
steps,
workspaces,
kanbanIsLoading,
tasksWithRepositories,
dialogSteps,
};
}
type SheetNavOptions = {
workspaceId: string | null;
store: ReturnType<typeof useAppStoreApi>;
loadTaskSessionsForTask: (
taskId: string,
) => Promise<Array<{ id: string; updated_at?: string | null }>>;
setActiveSession: (taskId: string, sessionId: string) => void;
setActiveTask: (taskId: string) => void;
onOpenChange: (open: boolean) => void;
};
async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) {
const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts;
store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } }));
try {
const workflowsResponse = await listWorkflows(newWorkspaceId, { cache: "no-store" });
const newWorkspaceWorkflows = workflowsResponse.workflows ?? [];
const firstWorkflow = newWorkspaceWorkflows[0];
if (!firstWorkflow) {
store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } }));
return;
}
const snapshot = await fetchWorkflowSnapshot(firstWorkflow.id);
store.setState((state) => ({
...state,
workflows: {
...state.workflows,
items: [
...state.workflows.items.filter(
(w: { workspaceId: string }) => w.workspaceId !== newWorkspaceId,
),
...newWorkspaceWorkflows.map((w) => ({
id: w.id,
workspaceId: w.workspace_id,
name: w.name,
})),
],
activeId: firstWorkflow.id,
},
kanban: mapSnapshotToKanban(snapshot, firstWorkflow.id),
}));
const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0];
if (mostRecentTask) {
const sessions = await loadTaskSessionsForTask(mostRecentTask.id);
const mostRecentSession = sortByUpdatedAtDesc(sessions)[0];
if (mostRecentSession) {
setActiveSession(mostRecentTask.id, mostRecentSession.id);
} else {
setActiveTask(mostRecentTask.id);
}
replaceTaskUrl(mostRecentTask.id);
}
onOpenChange(false);
} catch (error) {
console.error("Failed to switch workspace:", error);
store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } }));
}
}
function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) {
const { workspaceId, store, setActiveSession, setActiveTask, onOpenChange } = opts;
const handleWorkspaceChange = useCallback(
async (newWorkspaceId: string) => {
if (newWorkspaceId === workspaceId) return;
await switchWorkspace(newWorkspaceId, opts);
},
[workspaceId, opts],
);
const handleTaskCreated = useCallback(
(task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => {
store.setState((state) => {
if (state.kanban.workflowId !== task.workflow_id) return state;
const nextTask = {
id: task.id,
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description,
position: task.position ?? 0,
state: task.state,
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
updatedAt: task.updated_at,
primaryExecutorId: task.primary_executor_id ?? undefined,
primaryExecutorType: task.primary_executor_type ?? undefined,
primaryExecutorName: task.primary_executor_name ?? undefined,
isRemoteExecutor: task.is_remote_executor ?? false,
};
return {
...state,
kanban: {
...state.kanban,
tasks: state.kanban.tasks.some(
(item: KanbanState["tasks"][number]) => item.id === task.id,
)
? state.kanban.tasks.map((item: KanbanState["tasks"][number]) =>
item.id === task.id ? nextTask : item,
)
: [...state.kanban.tasks, nextTask],
},
};
});
setActiveTask(task.id);
if (meta?.taskSessionId) {
setActiveSession(task.id, meta.taskSessionId);
}
replaceTaskUrl(task.id);
onOpenChange(false);
},
[store, setActiveTask, setActiveSession, onOpenChange],
);
return { handleWorkspaceChange, handleTaskCreated };
}
function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) {
const setActiveTask = useAppStore((state) => state.setActiveTask);
const setActiveSession = useAppStore((state) => state.setActiveSession);
const store = useAppStoreApi();
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
const { deleteTaskById } = useTaskActions();
const archiveAndSwitch = useArchiveAndSwitchTask();
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store });
const handleSelectTask = useCallback(
(taskId: string) => {
const kanbanTasks = store.getState().kanban.tasks;
const task = kanbanTasks.find((t) => t.id === taskId);
if (task?.primarySessionId) {
setActiveSession(taskId, task.primarySessionId);
loadTaskSessionsForTask(taskId);
replaceTaskUrl(taskId);
onOpenChange(false);
return;
}
loadTaskSessionsForTask(taskId).then(async (sessions) => {
const sessionId = sessions[0]?.id ?? null;
if (sessionId) {
setActiveSession(taskId, sessionId);
replaceTaskUrl(taskId);
onOpenChange(false);
return;
}
// No session — prepare workspace
const { request } = buildPrepareRequest(taskId);
try {
const resp = await launchSession(request);
if (resp.session_id) {
setActiveSession(taskId, resp.session_id);
}
} catch {
// Fall through to default behavior
}
setActiveTask(taskId);
replaceTaskUrl(taskId);
onOpenChange(false);
});
},
[loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange],
);
const [archivingTask, setArchivingTask] = useState<{ id: string; title: string } | null>(null);
const [isArchiving, setIsArchiving] = useState(false);
const handleArchiveTask = useCallback(
(taskId: string) => {
const task = store.getState().kanban.tasks.find((t) => t.id === taskId);
setArchivingTask({ id: taskId, title: task?.title ?? "this task" });
},
[store],
);
const handleArchiveConfirm = useCallback(async () => {
if (!archivingTask) return;
setIsArchiving(true);
try {
await archiveAndSwitch(archivingTask.id);
} catch (error) {
console.error("Failed to archive task:", error);
} finally {
setIsArchiving(false);
setArchivingTask(null);
}
}, [archivingTask, archiveAndSwitch]);
const handleDeleteTask = useCallback(
async (taskId: string) => {
setDeletingTaskId(taskId);
// Capture active state before the async API call — the WS "task.deleted"
// handler may clear activeTaskId/activeSessionId before removeTaskFromBoard runs.
const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } =
store.getState().tasks;
try {
await deleteTaskById(taskId);
await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId });
} finally {
setDeletingTaskId(null);
}
},
[deleteTaskById, removeTaskFromBoard, store],
);
const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({
workspaceId,
store,
loadTaskSessionsForTask,
setActiveSession,
setActiveTask,
onOpenChange,
});
return {
deletingTaskId,
handleSelectTask,
handleArchiveTask,
handleDeleteTask,
handleWorkspaceChange,
handleTaskCreated,
archivingTask,
setArchivingTask,
isArchiving,
handleArchiveConfirm,
};
}
export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
open,
onOpenChange,
workspaceId,
workflowId,
}: SessionTaskSwitcherSheetProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const {
activeTaskId,
selectedTaskId,
steps,
workspaces,
kanbanIsLoading,
tasksWithRepositories,
dialogSteps,
} = useSheetData(workspaceId, workflowId);
const {
deletingTaskId,
handleSelectTask,
handleArchiveTask,
handleDeleteTask,
handleWorkspaceChange,
handleTaskCreated,
archivingTask,
setArchivingTask,
isArchiving,
handleArchiveConfirm,
} = useSheetActions(workspaceId, onOpenChange);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
showCloseButton={false}
side="left"
className="w-[85vw] max-w-sm p-0 flex flex-col"
>
<SheetHeader className="p-4 pb-2 border-b border-border">
<div className="flex items-center justify-between">
<SheetTitle className="text-base">Tasks</SheetTitle>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 cursor-pointer"
onClick={() => setDialogOpen(true)}
>
<IconPlus className="h-4 w-4" />
New
</Button>
</div>
<div className="pt-2">
<WorkspaceSwitcher
workspaces={workspaces.map((w: Workspace) => ({ id: w.id, name: w.name }))}
activeWorkspaceId={workspaceId}
onSelect={handleWorkspaceChange}
/>
</div>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto p-2">
<TaskSwitcher
tasks={tasksWithRepositories}
steps={steps.map((step: KanbanState["steps"][number]) => ({
id: step.id,
title: step.title,
color: step.color,
}))}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
onSelectTask={handleSelectTask}
onArchiveTask={handleArchiveTask}
onDeleteTask={handleDeleteTask}
deletingTaskId={deletingTaskId}
isLoading={kanbanIsLoading}
/>
</div>
</SheetContent>
<TaskCreateDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode="create"
workspaceId={workspaceId}
workflowId={workflowId}
defaultStepId={dialogSteps[0]?.id ?? null}
steps={dialogSteps}
onSuccess={handleTaskCreated}
/>
<TaskArchiveConfirmDialog
open={archivingTask !== null}
onOpenChange={(open) => {
if (!open) setArchivingTask(null);
}}
taskTitle={archivingTask?.title ?? ""}
isArchiving={isArchiving}
onConfirm={handleArchiveConfirm}
/>
</Sheet>
);
});