feat: introduce subtasks, allow sessions to task reuse executor (#419)
This commit is contained in:
@@ -1,303 +0,0 @@
|
||||
import { StateHydrator } from "@/components/state-hydrator";
|
||||
import { readLayoutDefaults } from "@/lib/layout/read-layout-defaults";
|
||||
import {
|
||||
fetchWorkflowSnapshot,
|
||||
fetchTaskSession,
|
||||
fetchTask,
|
||||
fetchUserSettings,
|
||||
listAgents,
|
||||
listWorkflows,
|
||||
listRepositories,
|
||||
listTaskSessionMessages,
|
||||
listTaskSessions,
|
||||
listWorkspaces,
|
||||
} from "@/lib/api";
|
||||
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
|
||||
import { listSessionTurns } from "@/lib/api/domains/session-api";
|
||||
import { fetchTerminals } from "@/lib/api/domains/user-shell-api";
|
||||
import type {
|
||||
ListMessagesResponse,
|
||||
Task,
|
||||
TaskSession,
|
||||
UserSettingsResponse,
|
||||
WorkflowSnapshot,
|
||||
} from "@/lib/types/http";
|
||||
import type { Terminal } from "@/hooks/domains/session/use-terminals";
|
||||
import { snapshotToState, taskToState } from "@/lib/ssr/mapper";
|
||||
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
|
||||
import { TaskPageContent } from "@/components/task/task-page-content";
|
||||
|
||||
function buildWorktreeState(allSessions: TaskSession[]) {
|
||||
const sessionsWithWorktrees = allSessions.filter((s) => s.worktree_id);
|
||||
return {
|
||||
worktrees: {
|
||||
items: Object.fromEntries(
|
||||
sessionsWithWorktrees.map((s) => [
|
||||
s.worktree_id,
|
||||
{
|
||||
id: s.worktree_id!,
|
||||
sessionId: s.id,
|
||||
repositoryId: s.repository_id ?? undefined,
|
||||
path: s.worktree_path ?? undefined,
|
||||
branch: s.worktree_branch ?? undefined,
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
sessionWorktreesBySessionId: {
|
||||
itemsBySessionId: Object.fromEntries(
|
||||
sessionsWithWorktrees.map((s) => [s.id, [s.worktree_id!]]),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type BuildSessionPageStateParams = {
|
||||
task: Task;
|
||||
sessionId: string;
|
||||
snapshot: Awaited<ReturnType<typeof fetchWorkflowSnapshot>>;
|
||||
agents: Awaited<ReturnType<typeof listAgents>>;
|
||||
repositories: Awaited<ReturnType<typeof listRepositories>>["repositories"];
|
||||
allSessions: TaskSession[];
|
||||
workspaces: Awaited<ReturnType<typeof listWorkspaces>>["workspaces"];
|
||||
workflows: Awaited<ReturnType<typeof listWorkflows>>["workflows"];
|
||||
turns: Awaited<ReturnType<typeof listSessionTurns>>["turns"];
|
||||
userSettingsResponse: UserSettingsResponse | null;
|
||||
messagesResponse: ListMessagesResponse | null;
|
||||
};
|
||||
|
||||
function buildSessionPageState(p: BuildSessionPageStateParams) {
|
||||
const {
|
||||
task,
|
||||
sessionId,
|
||||
snapshot,
|
||||
agents,
|
||||
repositories,
|
||||
allSessions,
|
||||
workspaces,
|
||||
workflows,
|
||||
turns,
|
||||
userSettingsResponse,
|
||||
messagesResponse,
|
||||
} = p;
|
||||
|
||||
const repositoryId = task.repositories?.[0]?.repository_id;
|
||||
const repository = repositories.find((r) => r.id === repositoryId);
|
||||
const scripts = repository?.scripts ?? [];
|
||||
const messages = messagesResponse?.messages ? [...messagesResponse.messages].reverse() : [];
|
||||
|
||||
const taskState = taskToState(task, sessionId, {
|
||||
items: messages,
|
||||
hasMore: messagesResponse?.has_more ?? false,
|
||||
oldestCursor: messages[0]?.id ?? null,
|
||||
});
|
||||
|
||||
const repositoryScripts = repositoryId
|
||||
? {
|
||||
itemsByRepositoryId: { [repositoryId]: scripts },
|
||||
loadingByRepositoryId: { [repositoryId]: false },
|
||||
loadedByRepositoryId: { [repositoryId]: true },
|
||||
}
|
||||
: {
|
||||
itemsByRepositoryId: {},
|
||||
loadingByRepositoryId: {},
|
||||
loadedByRepositoryId: {},
|
||||
};
|
||||
|
||||
return {
|
||||
...snapshotToState(snapshot),
|
||||
...taskState,
|
||||
workspaces: {
|
||||
items: workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
description: workspace.description ?? null,
|
||||
owner_id: workspace.owner_id,
|
||||
default_executor_id: workspace.default_executor_id ?? null,
|
||||
default_environment_id: workspace.default_environment_id ?? null,
|
||||
default_agent_profile_id: workspace.default_agent_profile_id ?? null,
|
||||
created_at: workspace.created_at,
|
||||
updated_at: workspace.updated_at,
|
||||
})),
|
||||
activeId: task.workspace_id,
|
||||
},
|
||||
workflows: {
|
||||
items: workflows.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
workspaceId: workflow.workspace_id,
|
||||
name: workflow.name,
|
||||
})),
|
||||
activeId: task.workflow_id,
|
||||
},
|
||||
repositories: {
|
||||
itemsByWorkspaceId: { [task.workspace_id]: repositories },
|
||||
loadingByWorkspaceId: { [task.workspace_id]: false },
|
||||
loadedByWorkspaceId: { [task.workspace_id]: true },
|
||||
},
|
||||
repositoryScripts,
|
||||
agentProfiles: {
|
||||
items: agents.agents.flatMap((agent) =>
|
||||
agent.profiles.map((profile) => toAgentProfileOption(agent, profile)),
|
||||
),
|
||||
version: 0,
|
||||
},
|
||||
taskSessions: {
|
||||
items: Object.fromEntries(allSessions.map((s) => [s.id, s])),
|
||||
},
|
||||
taskSessionsByTask: {
|
||||
itemsByTaskId: { [task.id]: allSessions },
|
||||
loadingByTaskId: { [task.id]: false },
|
||||
loadedByTaskId: { [task.id]: true },
|
||||
},
|
||||
turns: {
|
||||
bySession: { [sessionId]: turns },
|
||||
activeBySession: {
|
||||
[sessionId]: turns.filter((t) => !t.completed_at).pop()?.id ?? null,
|
||||
},
|
||||
},
|
||||
...buildWorktreeState(allSessions),
|
||||
settingsAgents: { items: agents.agents },
|
||||
settingsData: {
|
||||
agentsLoaded: true,
|
||||
executorsLoaded: false,
|
||||
},
|
||||
userSettings: mapUserSettingsResponse(userSettingsResponse),
|
||||
};
|
||||
}
|
||||
|
||||
type FetchedSessionData = {
|
||||
task: Task;
|
||||
sessionId: string;
|
||||
initialState: ReturnType<typeof taskToState>;
|
||||
initialTerminals: Terminal[];
|
||||
};
|
||||
|
||||
async function fetchSessionData(paramSessionId: string): Promise<FetchedSessionData> {
|
||||
const sessionResponse = await fetchTaskSession(paramSessionId, { cache: "no-store" });
|
||||
const session = sessionResponse.session;
|
||||
if (!session?.task_id) {
|
||||
throw new Error("No task_id found for session");
|
||||
}
|
||||
|
||||
const task = await fetchTask(session.task_id, { cache: "no-store" });
|
||||
const [
|
||||
snapshot,
|
||||
agents,
|
||||
repositoriesResponse,
|
||||
allSessionsResponse,
|
||||
workspacesResponse,
|
||||
workflowsResponse,
|
||||
turnsResponse,
|
||||
userSettingsResponse,
|
||||
terminalsResponse,
|
||||
messagesResponse,
|
||||
] = await Promise.all([
|
||||
// Ephemeral tasks (quick chat) have empty workflow_id - skip snapshot fetch
|
||||
task.workflow_id
|
||||
? fetchWorkflowSnapshot(task.workflow_id, { cache: "no-store" })
|
||||
: Promise.resolve({ steps: [], tasks: [] } as unknown as WorkflowSnapshot),
|
||||
listAgents({ cache: "no-store" }),
|
||||
listRepositories(task.workspace_id, { includeScripts: true }, { cache: "no-store" }),
|
||||
listTaskSessions(session.task_id, { cache: "no-store" }),
|
||||
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
|
||||
listWorkflows(task.workspace_id, { cache: "no-store" }).catch(() => ({ workflows: [] })),
|
||||
listSessionTurns(paramSessionId, { cache: "no-store" }).catch(() => ({ turns: [], total: 0 })),
|
||||
fetchUserSettings({ cache: "no-store" }).catch(() => null),
|
||||
fetchTerminals(paramSessionId).catch(() => []),
|
||||
listTaskSessionMessages(
|
||||
paramSessionId,
|
||||
{ limit: 50, sort: "desc" },
|
||||
{ cache: "no-store" },
|
||||
).catch(() => null as ListMessagesResponse | null),
|
||||
]);
|
||||
|
||||
const repositories = repositoriesResponse.repositories ?? [];
|
||||
const allSessions = allSessionsResponse.sessions ?? [session];
|
||||
const workspaces = workspacesResponse.workspaces ?? [];
|
||||
const workflows = workflowsResponse.workflows ?? [];
|
||||
const turns = turnsResponse.turns ?? [];
|
||||
|
||||
const initialTerminals: Terminal[] = terminalsResponse.map((t) => ({
|
||||
id: t.terminal_id,
|
||||
type: t.initial_command ? ("script" as const) : ("shell" as const),
|
||||
label: t.label,
|
||||
closable: t.closable,
|
||||
}));
|
||||
|
||||
const initialState = buildSessionPageState({
|
||||
task,
|
||||
sessionId: paramSessionId,
|
||||
snapshot,
|
||||
agents,
|
||||
repositories,
|
||||
allSessions,
|
||||
workspaces,
|
||||
workflows,
|
||||
turns,
|
||||
userSettingsResponse,
|
||||
messagesResponse,
|
||||
});
|
||||
|
||||
return { task, sessionId: paramSessionId, initialState, initialTerminals };
|
||||
}
|
||||
|
||||
function extractInitialRepositories(
|
||||
initialState: FetchedSessionData["initialState"] | null,
|
||||
task: Task | null,
|
||||
) {
|
||||
return initialState?.repositories?.itemsByWorkspaceId?.[task?.workspace_id ?? ""] ?? [];
|
||||
}
|
||||
|
||||
function extractInitialScripts(
|
||||
initialState: FetchedSessionData["initialState"] | null,
|
||||
task: Task | null,
|
||||
) {
|
||||
const repoId = task?.repositories?.[0]?.repository_id ?? "";
|
||||
return initialState?.repositoryScripts?.itemsByRepositoryId?.[repoId] ?? [];
|
||||
}
|
||||
|
||||
export default async function SessionPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>;
|
||||
searchParams: Promise<{ layout?: string }>;
|
||||
}) {
|
||||
let fetchedData: FetchedSessionData | null = null;
|
||||
const defaultLayouts = await readLayoutDefaults();
|
||||
const { layout: initialLayout } = await searchParams;
|
||||
|
||||
try {
|
||||
const { sessionId: paramSessionId } = await params;
|
||||
fetchedData = await fetchSessionData(paramSessionId);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Could not SSR session (client will load via WebSocket):",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
const { task, sessionId, initialState, initialTerminals } = fetchedData ?? {
|
||||
task: null,
|
||||
sessionId: null,
|
||||
initialState: null,
|
||||
initialTerminals: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{initialState ? (
|
||||
<StateHydrator initialState={initialState} sessionId={sessionId ?? undefined} />
|
||||
) : null}
|
||||
<TaskPageContent
|
||||
task={task}
|
||||
sessionId={sessionId}
|
||||
initialRepositories={extractInitialRepositories(initialState, task)}
|
||||
initialScripts={extractInitialScripts(initialState, task)}
|
||||
initialTerminals={initialTerminals}
|
||||
defaultLayouts={defaultLayouts}
|
||||
initialLayout={initialLayout}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { StateHydrator } from "@/components/state-hydrator";
|
||||
import { readLayoutDefaults } from "@/lib/layout/read-layout-defaults";
|
||||
import { TaskPageContent } from "@/components/task/task-page-content";
|
||||
import {
|
||||
type FetchedSessionData,
|
||||
fetchSessionDataForTask,
|
||||
extractInitialRepositories,
|
||||
extractInitialScripts,
|
||||
} from "@/lib/ssr/session-page-state";
|
||||
|
||||
export default async function TaskPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ taskId: string }>;
|
||||
searchParams: Promise<{ layout?: string }>;
|
||||
}) {
|
||||
let fetchedData: FetchedSessionData | null = null;
|
||||
const defaultLayouts = await readLayoutDefaults();
|
||||
const { layout: initialLayout } = await searchParams;
|
||||
const { taskId } = await params;
|
||||
|
||||
try {
|
||||
fetchedData = await fetchSessionDataForTask(taskId);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Could not SSR task page (client will load via WebSocket):",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
const { task, sessionId, initialState, initialTerminals } = fetchedData ?? {
|
||||
task: null,
|
||||
sessionId: null,
|
||||
initialState: null,
|
||||
initialTerminals: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{initialState ? (
|
||||
<StateHydrator initialState={initialState} sessionId={sessionId ?? undefined} />
|
||||
) : null}
|
||||
<TaskPageContent
|
||||
task={task}
|
||||
taskId={taskId}
|
||||
sessionId={sessionId}
|
||||
initialRepositories={extractInitialRepositories(initialState, task)}
|
||||
initialScripts={extractInitialScripts(initialState, task)}
|
||||
initialTerminals={initialTerminals}
|
||||
defaultLayouts={defaultLayouts}
|
||||
initialLayout={initialLayout}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ function TitleCell({
|
||||
repoMap: Map<string, string>;
|
||||
}) {
|
||||
const task = row.original;
|
||||
const sessionId = task.primary_session_id;
|
||||
const isArchived = !!task.archived_at;
|
||||
const repoName = task.repositories?.[0]
|
||||
? repoMap.get(task.repositories[0].repository_id)
|
||||
@@ -40,13 +39,9 @@ function TitleCell({
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 py-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{sessionId ? (
|
||||
<Link href={`/s/${sessionId}`} className="text-primary font-medium text-sm">
|
||||
{task.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium text-sm">{task.title}</span>
|
||||
)}
|
||||
<Link href={`/t/${task.id}`} className="text-primary font-medium text-sm">
|
||||
{task.title}
|
||||
</Link>
|
||||
{isArchived && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@@ -301,7 +301,6 @@ function useTasksPageComputed({
|
||||
handleDelete,
|
||||
deletingTaskId,
|
||||
router,
|
||||
toast,
|
||||
activeWorkflowId,
|
||||
}: {
|
||||
total: number;
|
||||
@@ -313,7 +312,6 @@ function useTasksPageComputed({
|
||||
handleDelete: (taskId: string) => Promise<void>;
|
||||
deletingTaskId: string | null;
|
||||
router: ReturnType<typeof useRouter>;
|
||||
toast: ReturnType<typeof useToast>["toast"];
|
||||
activeWorkflowId: string | null;
|
||||
}) {
|
||||
const pageCount = useMemo(
|
||||
@@ -334,16 +332,9 @@ function useTasksPageComputed({
|
||||
);
|
||||
const handleRowClick = useCallback(
|
||||
(task: Task) => {
|
||||
if (task.primary_session_id) {
|
||||
router.push(`/s/${task.primary_session_id}`);
|
||||
} else {
|
||||
toast({
|
||||
title: "No session available",
|
||||
description: "This task has no associated session yet.",
|
||||
});
|
||||
}
|
||||
router.push(`/t/${task.id}`);
|
||||
},
|
||||
[router, toast],
|
||||
[router],
|
||||
);
|
||||
const defaultWorkflow = activeWorkflowId
|
||||
? workflows.find((w) => w.id === activeWorkflowId)
|
||||
@@ -360,7 +351,6 @@ function useTasksPageSetup(props: TasksPageClientProps) {
|
||||
activeWorkflowId,
|
||||
repositories: storeRepositories,
|
||||
} = useKanbanDisplaySettings();
|
||||
const { toast } = useToast();
|
||||
const viewState = useTasksPageViewState({
|
||||
initialWorkflows: props.initialWorkflows,
|
||||
initialSteps: props.initialSteps,
|
||||
@@ -396,7 +386,6 @@ function useTasksPageSetup(props: TasksPageClientProps) {
|
||||
handleDelete: ops.handleDelete,
|
||||
deletingTaskId: ops.deletingTaskId,
|
||||
router,
|
||||
toast,
|
||||
activeWorkflowId,
|
||||
});
|
||||
return { ...viewState, ...ops, ...computed, activeWorkspaceId, debouncedQuery };
|
||||
|
||||
Reference in New Issue
Block a user