feat: introduce subtasks, allow sessions to task reuse executor (#419)

This commit is contained in:
José Almeida
2026-03-29 15:33:40 +01:00
committed by GitHub
parent ac41dec90c
commit d1a028fdb6
188 changed files with 7507 additions and 1638 deletions
-303
View File
@@ -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}
/>
</>
);
}
+56
View File
@@ -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}
/>
</>
);
}
+3 -8
View File
@@ -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"
+2 -13
View File
@@ -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 };