diff --git a/backend/app/api/work.py b/backend/app/api/work.py index 2a01510..bb1de89 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -102,6 +102,32 @@ def create_task( return Task.model_validate(task) +@router.post("/tasks/{task_id}/dispatch") +def dispatch_task( + task_id: int, + background: BackgroundTasks, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + task = session.get(Task, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + if task.assignee_employee_id is None: + raise HTTPException(status_code=400, detail="Task has no assignee") + + _validate_task_assignee(session, task.assignee_employee_id) + + # Best-effort: enqueue an agent dispatch. This does not mutate the task. + background.add_task( + notify_openclaw, + session, + NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task=task), + ) + + return {"ok": True} + + @router.patch("/tasks/{task_id}", response_model=Task) def update_task( task_id: int, diff --git a/frontend/src/api/generated/work/work.ts b/frontend/src/api/generated/work/work.ts index 6ad2a47..505d893 100644 --- a/frontend/src/api/generated/work/work.ts +++ b/frontend/src/api/generated/work/work.ts @@ -351,6 +351,122 @@ export const useCreateTaskTasksPost = < queryClient, ); }; +/** + * @summary Dispatch Task + */ +export type dispatchTaskTasksTaskIdDispatchPostResponse200 = { + data: unknown; + status: 200; +}; + +export type dispatchTaskTasksTaskIdDispatchPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type dispatchTaskTasksTaskIdDispatchPostResponseSuccess = + dispatchTaskTasksTaskIdDispatchPostResponse200 & { + headers: Headers; + }; +export type dispatchTaskTasksTaskIdDispatchPostResponseError = + dispatchTaskTasksTaskIdDispatchPostResponse422 & { + headers: Headers; + }; + +export type dispatchTaskTasksTaskIdDispatchPostResponse = + | dispatchTaskTasksTaskIdDispatchPostResponseSuccess + | dispatchTaskTasksTaskIdDispatchPostResponseError; + +export const getDispatchTaskTasksTaskIdDispatchPostUrl = (taskId: number) => { + return `/tasks/${taskId}/dispatch`; +}; + +export const dispatchTaskTasksTaskIdDispatchPost = async ( + taskId: number, + options?: RequestInit, +): Promise => { + return customFetch( + getDispatchTaskTasksTaskIdDispatchPostUrl(taskId), + { + ...options, + method: "POST", + }, + ); +}; + +export const getDispatchTaskTasksTaskIdDispatchPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { taskId: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { taskId: number }, + TContext +> => { + const mutationKey = ["dispatchTaskTasksTaskIdDispatchPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { taskId: number } + > = (props) => { + const { taskId } = props ?? {}; + + return dispatchTaskTasksTaskIdDispatchPost(taskId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DispatchTaskTasksTaskIdDispatchPostMutationResult = NonNullable< + Awaited> +>; + +export type DispatchTaskTasksTaskIdDispatchPostMutationError = + HTTPValidationError; + +/** + * @summary Dispatch Task + */ +export const useDispatchTaskTasksTaskIdDispatchPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { taskId: number }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { taskId: number }, + TContext +> => { + return useMutation( + getDispatchTaskTasksTaskIdDispatchPostMutationOptions(options), + queryClient, + ); +}; /** * @summary Update Task */ diff --git a/frontend/src/app/projects/[id]/page.tsx b/frontend/src/app/projects/[id]/page.tsx index c1fe7df..253faeb 100644 --- a/frontend/src/app/projects/[id]/page.tsx +++ b/frontend/src/app/projects/[id]/page.tsx @@ -4,7 +4,13 @@ import { useState } from "react"; import { useParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; @@ -14,15 +20,16 @@ import { useListProjectsProjectsGet } from "@/api/generated/projects/projects"; import { useListEmployeesEmployeesGet } from "@/api/generated/org/org"; import { useCreateTaskTasksPost, + useDeleteTaskTasksTaskIdDelete, + useDispatchTaskTasksTaskIdDispatchPost, + useListTaskCommentsTaskCommentsGet, useListTasksTasksGet, useUpdateTaskTasksTaskIdPatch, - useDeleteTaskTasksTaskIdDelete, useCreateTaskCommentTaskCommentsPost, - useListTaskCommentsTaskCommentsGet, } from "@/api/generated/work/work"; import { - useListProjectMembersProjectsProjectIdMembersGet, useAddProjectMemberProjectsProjectIdMembersPost, + useListProjectMembersProjectsProjectIdMembersGet, useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete, useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch, } from "@/api/generated/projects/projects"; @@ -51,7 +58,10 @@ export default function ProjectDetailPage() { const employees = useListEmployeesEmployeesGet(); const employeeList = employees.data?.status === 200 ? employees.data.data : []; - const eligibleAssignees = employeeList.filter((e) => e.employee_type !== "agent" || !!e.openclaw_session_key); + + const eligibleAssignees = employeeList.filter( + (e) => e.employee_type !== "agent" || !!e.openclaw_session_key, + ); const members = useListProjectMembersProjectsProjectIdMembersGet(projectId); const memberList = members.data?.status === 200 ? members.data.data : []; @@ -76,6 +86,11 @@ export default function ProjectDetailPage() { const deleteTask = useDeleteTaskTasksTaskIdDelete({ mutation: { onSuccess: () => tasks.refetch() }, }); + const dispatchTask = useDispatchTaskTasksTaskIdDispatchPost({ + mutation: { + onSuccess: () => tasks.refetch(), + }, + }); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -110,6 +125,11 @@ export default function ProjectDetailPage() { return map; })(); + const employeeById = new Map(); + for (const e of employeeList) { + if (e.id != null) employeeById.set(Number(e.id), e); + } + const employeeName = (id: number | null | undefined) => employeeList.find((e) => e.id === id)?.name ?? "—"; @@ -128,16 +148,40 @@ export default function ProjectDetailPage() { {projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? (
Loading…
) : null} - {projects.error ?
{(projects.error as Error).message}
: null} - {employees.error ?
{(employees.error as Error).message}
: null} - {members.error ?
{(members.error as Error).message}
: null} - {tasks.error ?
{(tasks.error as Error).message}
: null} + {projects.error ? ( +
+ {(projects.error as Error).message} +
+ ) : null} + {employees.error ? ( +
+ {(employees.error as Error).message} +
+ ) : null} + {members.error ? ( +
{(members.error as Error).message}
+ ) : null} + {tasks.error ? ( +
{(tasks.error as Error).message}
+ ) : null} +
-

{project?.name ?? `Project #${projectId}`}

-

Project detail: staffing + tasks.

+

+ {project?.name ?? `Project #${projectId}`} +

+

+ Project detail: staffing + tasks. +

-
@@ -149,14 +193,31 @@ export default function ProjectDetailPage() { Project-scoped tasks - {createTask.error ?
{(createTask.error as Error).message}
: null} - setTitle(e.target.value)} /> -