8df1bda87b
* fix(web): show tasks from all workflows in the mobile task-switcher sheet The mobile sheet read tasks via useTasks(workflowId), which gated on kanban.workflowId === workflowId and returned [] otherwise. On a fresh /t/[id] load the gate often failed (or the snapshot fetch was still in flight / silently failing) and the sheet rendered "No tasks yet." Align mobile with desktop: read from kanbanMulti.snapshots via a new shared useWorkspaceSidebarTasks hook that does the per-workspace aggregation (incl. the active-kanban fallback), and pass workflows + stepsByWorkflowId through so the existing TaskSwitcher grouping kicks in. The desktop sidebar now delegates to the same hook. * refactor(web): address review comments on PR #1025 - Extract findTaskInSnapshots to task-select-helpers.ts (shared between desktop sidebar and mobile sheet). Mobile passes kanban.tasks as the optional fallback so it still locates tasks that arrived via WS before the snapshot fetch landed. - Build workflowNameById from the already-scoped `workflows` array instead of the raw, unscoped kanbanMulti.snapshots. Stops the memo from re-running on snapshot updates from other workspaces and removes the risk of a stale workflow name leaking across a workspace switch. Same fix in both the desktop sidebar and the mobile sheet. - Collapse the duplicated workflow filter in useWorkspaceSidebarTasks into a single `filteredWorkflows` memo. * fix(web): empty scope when workspaceId is unresolved in mobile/desktop sidebar hook When `useWorkspaceSidebarTasks` was called with a null workspaceId (the initial SSR / pre-hydration state on /t/[id]) it would widen to every workflow in the store, briefly leaking snapshots from previously-active workspaces into the sidebar. Return an empty scope until the workspace resolves instead, matching the "no cross-workspace bleed" intent of the original useScopedAggregation comment. Adds a regression test, plus splits the test file's describe block so the linter's 100-lines-per-function cap doesn't trip. * refactor(web): consolidate findTaskInSnapshots and drop unused useSheetData.steps - Reuse the canonical helper in `lib/kanban/find-task.ts` instead of duplicating it in `task-select-helpers.ts`. Extend the canonical one with an optional `fallbackTasks` parameter so the mobile sheet can still locate tasks via `state.kanban.tasks` when the snapshot fetch hasn't landed yet — desktop call sites continue to omit the fallback. - `useSheetData` was returning the raw active-kanban `steps` slice next to the already-mapped `dialogSteps`. The sheet component only consumes `dialogSteps`; drop the unused field. * test(web): add unit tests for findTaskInSnapshots fallback path * fix(web): pass active-kanban fallback into desktop sidebar task lookups useWorkspaceSidebarTasks can render tasks from the active kanban slice before kanbanMulti.snapshots catches up, but the desktop sidebar's archive/delete/select handlers were searching snapshots only — leaving fallback-only tasks unresolved during the snapshot race window. Pass state.kanban.tasks into findTaskInSnapshots in all three handlers so the dialogs find the right title and selectTaskWithLayout sees the task. Also trims the multi-line comment in lib/kanban/find-task.ts to a single line per CLAUDE.md style guidance. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
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 { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
|
|
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
|
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
|
import { getSessionInfoForTask } from "@/lib/utils/session-info";
|
|
import {
|
|
hasPendingClarificationForSession,
|
|
hasPendingPermissionForSession,
|
|
} from "@/lib/utils/pending-clarification";
|
|
import {
|
|
repositoryId as toRepositoryId,
|
|
type TaskState,
|
|
type TaskSessionState,
|
|
type Repository,
|
|
type Task,
|
|
type WorkflowSnapshot,
|
|
} from "@/lib/types/http";
|
|
import type { KanbanState } from "@/lib/state/slices";
|
|
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
|
import { resolvePreferredSessionId } from "../task-select-helpers";
|
|
|
|
// Map workflow snapshot to kanban state on workspace switch.
|
|
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,
|
|
// Carry optional step capabilities forward so downstream UI doesn't see
|
|
// them as missing after a workspace switch (until a full reload).
|
|
allow_manual_move: step.allow_manual_move,
|
|
prompt: step.prompt,
|
|
is_start_step: step.is_start_step,
|
|
show_in_command_panel: step.show_in_command_panel,
|
|
agent_profile_id: step.agent_profile_id,
|
|
})),
|
|
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,
|
|
// Carry the full TaskRepository array so the mobile repo picker
|
|
// (useTaskRepoCount + MobileReposSection) keeps working after a
|
|
// workspace switch. Without this, the picker silently disappears for
|
|
// multi-repo tasks because length defaults to 0.
|
|
repositories: task.repositories?.map((r) => ({
|
|
id: r.id,
|
|
repository_id: r.repository_id,
|
|
base_branch: r.base_branch,
|
|
checkout_branch: r.checkout_branch,
|
|
position: r.position,
|
|
})),
|
|
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,
|
|
})),
|
|
};
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
type SheetItemCtx = {
|
|
repositoryPathsById: Map<string, string | undefined>;
|
|
workflowNameById: Map<string, string>;
|
|
stepTitleById: Map<string, string>;
|
|
sessionsByTaskId: Parameters<typeof getSessionInfoForTask>[1];
|
|
gitStatusByEnvId: Parameters<typeof getSessionInfoForTask>[2];
|
|
envIdBySessionId: Parameters<typeof getSessionInfoForTask>[3];
|
|
messagesBySession: Parameters<typeof hasPendingClarificationForSession>[0];
|
|
};
|
|
|
|
function toSheetItem(
|
|
task: KanbanState["tasks"][number] & { _workflowId: string },
|
|
ctx: SheetItemCtx,
|
|
) {
|
|
const sessionInfo = getSessionInfoForTask(
|
|
task.id,
|
|
ctx.sessionsByTaskId,
|
|
ctx.gitStatusByEnvId,
|
|
ctx.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,
|
|
workflowId: task._workflowId,
|
|
workflowName: ctx.workflowNameById.get(task._workflowId),
|
|
workflowStepId: task.workflowStepId,
|
|
workflowStepTitle: ctx.stepTitleById.get(task.workflowStepId),
|
|
repositoryPath: task.repositoryId
|
|
? ctx.repositoryPathsById.get(toRepositoryId(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,
|
|
hasPendingClarification: hasPendingClarificationForSession(
|
|
ctx.messagesBySession,
|
|
task.primarySessionId,
|
|
),
|
|
hasPendingPermission: hasPendingPermissionForSession(
|
|
ctx.messagesBySession,
|
|
task.primarySessionId,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function useSheetData(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 sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId);
|
|
const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId);
|
|
const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId);
|
|
const messagesBySession = useAppStore((state) => state.messages.bySession);
|
|
const {
|
|
allTasks,
|
|
allSteps,
|
|
stepsByWorkflowId,
|
|
workflows,
|
|
isLoading: tasksLoading,
|
|
} = useWorkspaceSidebarTasks(workspaceId);
|
|
const steps = useAppStore((state) => state.kanban.steps);
|
|
const workspaces = useAppStore((state) => state.workspaces.items);
|
|
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
|
|
|
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 ctx: SheetItemCtx = {
|
|
repositoryPathsById: new Map(
|
|
repositories.map((repo: Repository) => [repo.id, repo.local_path]),
|
|
),
|
|
workflowNameById: new Map(workflows.map((w) => [w.id, w.name])),
|
|
stepTitleById: new Map(allSteps.map((s) => [s.id, s.title])),
|
|
sessionsByTaskId,
|
|
gitStatusByEnvId,
|
|
envIdBySessionId,
|
|
messagesBySession,
|
|
};
|
|
return allTasks.map((task) => toSheetItem(task, ctx));
|
|
}, [
|
|
repositoriesByWorkspace,
|
|
allTasks,
|
|
allSteps,
|
|
workflows,
|
|
workspaceId,
|
|
sessionsByTaskId,
|
|
gitStatusByEnvId,
|
|
envIdBySessionId,
|
|
messagesBySession,
|
|
]);
|
|
|
|
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,
|
|
workspaces,
|
|
workflows,
|
|
stepsByWorkflowId,
|
|
// Skeleton while the first snapshot fetch is in flight — otherwise shows "No tasks yet." even when tasks exist.
|
|
tasksLoading,
|
|
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",
|
|
includeHidden: true,
|
|
});
|
|
const newWorkspaceWorkflows = workflowsResponse.workflows ?? [];
|
|
const firstWorkflow = newWorkspaceWorkflows.find((w) => !w.hidden);
|
|
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,
|
|
hidden: w.hidden,
|
|
})),
|
|
],
|
|
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 mapTaskRepositories(
|
|
repositories: Task["repositories"],
|
|
): KanbanState["tasks"][number]["repositories"] {
|
|
return repositories?.map((r) => ({
|
|
id: r.id,
|
|
repository_id: r.repository_id,
|
|
base_branch: r.base_branch,
|
|
checkout_branch: r.checkout_branch,
|
|
position: r.position,
|
|
}));
|
|
}
|
|
|
|
function mergeSessionFields(
|
|
task: Task,
|
|
existing: KanbanState["tasks"][number] | undefined,
|
|
taskSessionId: string | null,
|
|
) {
|
|
return {
|
|
primarySessionId:
|
|
taskSessionId ?? task.primary_session_id ?? existing?.primarySessionId ?? undefined,
|
|
primarySessionState: task.primary_session_state ?? existing?.primarySessionState ?? undefined,
|
|
sessionCount: task.session_count ?? existing?.sessionCount ?? (taskSessionId ? 1 : undefined),
|
|
reviewStatus: task.review_status ?? existing?.reviewStatus ?? undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the kanban-store representation of a task for an upsert. Session-
|
|
* derived fields (primarySessionId, sessionCount, etc.) fall through new
|
|
* DTO → existing entry → meta.taskSessionId — that way an "edit" call doesn't
|
|
* wipe sessions the existing entry carried, and "create with session" still
|
|
* sets the primary correctly.
|
|
*/
|
|
function buildKanbanTaskUpsert(
|
|
task: Task,
|
|
existing: KanbanState["tasks"][number] | undefined,
|
|
meta: { taskSessionId?: string | null } | undefined,
|
|
): KanbanState["tasks"][number] {
|
|
const taskSessionId = meta?.taskSessionId ?? null;
|
|
return {
|
|
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,
|
|
repositories: mapTaskRepositories(task.repositories),
|
|
updatedAt: task.updated_at,
|
|
...mergeSessionFields(task, existing, taskSessionId),
|
|
primaryExecutorId: task.primary_executor_id ?? undefined,
|
|
primaryExecutorType: task.primary_executor_type ?? undefined,
|
|
primaryExecutorName: task.primary_executor_name ?? undefined,
|
|
isRemoteExecutor: task.is_remote_executor ?? false,
|
|
};
|
|
}
|
|
|
|
function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) {
|
|
const {
|
|
workspaceId,
|
|
store,
|
|
loadTaskSessionsForTask,
|
|
setActiveSession,
|
|
setActiveTask,
|
|
onOpenChange,
|
|
} = opts;
|
|
|
|
const handleWorkspaceChange = useCallback(
|
|
async (newWorkspaceId: string) => {
|
|
if (newWorkspaceId === workspaceId) return;
|
|
await switchWorkspace(newWorkspaceId, {
|
|
workspaceId,
|
|
store,
|
|
loadTaskSessionsForTask,
|
|
setActiveSession,
|
|
setActiveTask,
|
|
onOpenChange,
|
|
});
|
|
},
|
|
// Spread the individual fields rather than the `opts` object so callers
|
|
// re-passing a fresh literal each render don't defeat memoization.
|
|
[workspaceId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange],
|
|
);
|
|
|
|
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 existing = state.kanban.tasks.find(
|
|
(item: KanbanState["tasks"][number]) => item.id === task.id,
|
|
);
|
|
const nextTask = buildKanbanTaskUpsert(task, existing, meta);
|
|
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 };
|
|
}
|
|
|
|
type SelectTaskOptions = {
|
|
setActiveTask: (taskId: string) => void;
|
|
setActiveSession: (taskId: string, sessionId: string) => void;
|
|
loadTaskSessionsForTask: SheetNavOptions["loadTaskSessionsForTask"];
|
|
onOpenChange: (open: boolean) => void;
|
|
};
|
|
|
|
async function selectTaskWithoutPrimarySession(taskId: string, opts: SelectTaskOptions) {
|
|
const { setActiveTask, setActiveSession, loadTaskSessionsForTask, onOpenChange } = opts;
|
|
try {
|
|
const sessions = await loadTaskSessionsForTask(taskId);
|
|
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);
|
|
replaceTaskUrl(taskId);
|
|
onOpenChange(false);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Fall through to default navigation.
|
|
}
|
|
} catch (error) {
|
|
// Loading sessions can reject (network / 5xx). Don't strand the user;
|
|
// fall back to plain task navigation so URL + state still align with tap.
|
|
console.error("Failed to load sessions for task:", error);
|
|
}
|
|
setActiveTask(taskId);
|
|
replaceTaskUrl(taskId);
|
|
onOpenChange(false);
|
|
}
|
|
|
|
function useSheetDeleteActions(
|
|
store: ReturnType<typeof useAppStoreApi>,
|
|
removeTaskFromBoard: ReturnType<typeof useTaskRemoval>["removeTaskFromBoard"],
|
|
) {
|
|
const { deleteTaskById } = useTaskActions();
|
|
const [deletingTask, setDeletingTask] = useState<{ id: string; title: string } | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
const handleDeleteTask = useCallback(
|
|
(taskId: string) => {
|
|
const state = store.getState();
|
|
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
|
setDeletingTask({ id: taskId, title: task?.title ?? "this task" });
|
|
},
|
|
[store],
|
|
);
|
|
|
|
const handleDeleteConfirm = useCallback(
|
|
async (opts?: { cascade?: boolean }) => {
|
|
if (!deletingTask || isDeleting) return;
|
|
const taskId = deletingTask.id;
|
|
setIsDeleting(true);
|
|
// 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, opts);
|
|
await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId });
|
|
} catch (error) {
|
|
console.error("Failed to delete task:", error);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
setDeletingTask(null);
|
|
}
|
|
},
|
|
[deletingTask, isDeleting, deleteTaskById, removeTaskFromBoard, store],
|
|
);
|
|
|
|
const deletingTaskId = isDeleting ? (deletingTask?.id ?? null) : null;
|
|
|
|
return {
|
|
deletingTaskId,
|
|
deletingTask,
|
|
setDeletingTask,
|
|
isDeleting,
|
|
handleDeleteTask,
|
|
handleDeleteConfirm,
|
|
};
|
|
}
|
|
|
|
export 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 archiveAndSwitch = useArchiveAndSwitchTask();
|
|
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store });
|
|
const deleteActions = useSheetDeleteActions(store, removeTaskFromBoard);
|
|
|
|
const handleSelectTask = useCallback(
|
|
(taskId: string) => {
|
|
const state = store.getState();
|
|
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
|
if (task?.primarySessionId) {
|
|
const targetSessionId = resolvePreferredSessionId(
|
|
taskId,
|
|
task.primarySessionId,
|
|
state.tasks.lastSessionByTaskId,
|
|
state.environmentIdBySessionId,
|
|
);
|
|
setActiveSession(taskId, targetSessionId);
|
|
loadTaskSessionsForTask(taskId);
|
|
replaceTaskUrl(taskId);
|
|
onOpenChange(false);
|
|
return;
|
|
}
|
|
void selectTaskWithoutPrimarySession(taskId, {
|
|
setActiveTask,
|
|
setActiveSession,
|
|
loadTaskSessionsForTask,
|
|
onOpenChange,
|
|
});
|
|
},
|
|
[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 state = store.getState();
|
|
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
|
setArchivingTask({ id: taskId, title: task?.title ?? "this task" });
|
|
},
|
|
[store],
|
|
);
|
|
|
|
const handleArchiveConfirm = useCallback(
|
|
async (opts?: { cascade?: boolean }) => {
|
|
if (!archivingTask) return;
|
|
setIsArchiving(true);
|
|
try {
|
|
await archiveAndSwitch(archivingTask.id, opts);
|
|
} catch (error) {
|
|
console.error("Failed to archive task:", error);
|
|
} finally {
|
|
setIsArchiving(false);
|
|
setArchivingTask(null);
|
|
}
|
|
},
|
|
[archivingTask, archiveAndSwitch],
|
|
);
|
|
|
|
const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({
|
|
workspaceId,
|
|
store,
|
|
loadTaskSessionsForTask,
|
|
setActiveSession,
|
|
setActiveTask,
|
|
onOpenChange,
|
|
});
|
|
|
|
return {
|
|
handleSelectTask,
|
|
handleArchiveTask,
|
|
handleWorkspaceChange,
|
|
handleTaskCreated,
|
|
archivingTask,
|
|
setArchivingTask,
|
|
isArchiving,
|
|
handleArchiveConfirm,
|
|
...deleteActions,
|
|
};
|
|
}
|