From 2948eb11395ed874d3bd0daf20beda9a5cd5ab39 Mon Sep 17 00:00:00 2001
From: Lukas May
Date: Wed, 4 Mar 2026 09:06:44 +0100
Subject: [PATCH] feat: Add tiptap editor for task descriptions in slide-over
panel
- Add updateTask tRPC mutation (name, description fields)
- Replace static description with TiptapEditor in TaskSlideOver
- Auto-detect existing markdown descriptions and convert to tiptap JSON
- Debounced auto-save with flush on close/unmount
- Saving indicator in header
---
apps/server/trpc/routers/task.ts | 19 ++++
.../components/execution/TaskSlideOver.tsx | 91 +++++++++++++++----
docs/server-api.md | 1 +
3 files changed, 94 insertions(+), 17 deletions(-)
diff --git a/apps/server/trpc/routers/task.ts b/apps/server/trpc/routers/task.ts
index c0b5413..595323f 100644
--- a/apps/server/trpc/routers/task.ts
+++ b/apps/server/trpc/routers/task.ts
@@ -143,6 +143,25 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
return tasks.filter((t) => t.category !== 'detail');
}),
+ updateTask: publicProcedure
+ .input(z.object({
+ id: z.string().min(1),
+ name: z.string().min(1).optional(),
+ description: z.string().nullable().optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ const taskRepository = requireTaskRepository(ctx);
+ const existing = await taskRepository.findById(input.id);
+ if (!existing) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: `Task '${input.id}' not found`,
+ });
+ }
+ const { id, ...data } = input;
+ return taskRepository.update(id, data);
+ }),
+
deleteTask: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx
index 5a29e44..8df2a65 100644
--- a/apps/web/src/components/execution/TaskSlideOver.tsx
+++ b/apps/web/src/components/execution/TaskSlideOver.tsx
@@ -1,12 +1,13 @@
-import { useCallback, useEffect, useMemo } from "react";
+import { useCallback, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
+import { TiptapEditor } from "@/components/editor/TiptapEditor";
import { getCategoryConfig } from "@/lib/category";
-import { markdownToSafeHtml } from "@/lib/markdown-to-tiptap";
+import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
@@ -15,6 +16,7 @@ export function TaskSlideOver() {
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
const queueTaskMutation = trpc.queueTask.useMutation();
const deleteTaskMutation = trpc.deleteTask.useMutation();
+ const updateTaskMutation = trpc.updateTask.useMutation();
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
@@ -28,6 +30,46 @@ export function TaskSlideOver() {
return () => document.removeEventListener("keydown", onKeyDown);
}, [selectedEntry, close]);
+ // Debounced description save
+ const timerRef = useRef | null>(null);
+ const pendingRef = useRef<{ id: string; description: string } | null>(null);
+
+ const flushDescription = useCallback(() => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ if (pendingRef.current) {
+ const data = pendingRef.current;
+ pendingRef.current = null;
+ updateTaskMutation.mutate(data);
+ }
+ }, [updateTaskMutation]);
+
+ const handleDescriptionUpdate = useCallback(
+ (json: string) => {
+ const task = selectedEntry?.task;
+ if (!task) return;
+ pendingRef.current = { id: task.id, description: json };
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(flushDescription, 1000);
+ },
+ [selectedEntry?.task, flushDescription],
+ );
+
+ // Flush on close / unmount
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ if (pendingRef.current) {
+ const data = pendingRef.current;
+ pendingRef.current = null;
+ updateTaskMutation.mutate(data);
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const task = selectedEntry?.task ?? null;
const dependencies = selectedEntry?.blockedBy ?? [];
const dependents = selectedEntry?.dependents ?? [];
@@ -36,10 +78,18 @@ export function TaskSlideOver() {
dependencies.every((d) => d.status === "completed");
const canQueue = task !== null && task.status === "pending" && allDepsComplete;
- const descriptionHtml = useMemo(() => {
+ // Convert existing markdown description to tiptap JSON for the editor.
+ // If it's already valid tiptap JSON, use as-is.
+ const editorContent = useMemo(() => {
if (!task?.description) return null;
try {
- return markdownToSafeHtml(task.description);
+ const parsed = JSON.parse(task.description);
+ if (parsed?.type === "doc") return task.description;
+ } catch {
+ // Not JSON — treat as markdown
+ }
+ try {
+ return JSON.stringify(markdownToTiptapJson(task.description));
} catch {
return null;
}
@@ -56,7 +106,10 @@ export function TaskSlideOver() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
- onClick={close}
+ onClick={() => {
+ flushDescription();
+ close();
+ }}
/>
{/* Panel */}
@@ -77,9 +130,17 @@ export function TaskSlideOver() {
{selectedEntry.phaseName}
+ {updateTaskMutation.isPending && (
+
+ Saving...
+
+ )}
@@ -108,18 +169,14 @@ export function TaskSlideOver() {
- {/* Description */}
+ {/* Description — editable tiptap */}
- {descriptionHtml ? (
-
- ) : (
-
- No description
-
- )}
+
{/* Dependencies */}
diff --git a/docs/server-api.md b/docs/server-api.md
index d033b59..ff8e17b 100644
--- a/docs/server-api.md
+++ b/docs/server-api.md
@@ -73,6 +73,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| listTasks | query | Child tasks of parent |
| getTask | query | Single task |
| updateTaskStatus | mutation | Change task status |
+| updateTask | mutation | Update task fields (name, description) |
| createInitiativeTask | mutation | Create task on initiative |
| createPhaseTask | mutation | Create task on phase |
| listInitiativeTasks | query | All tasks for initiative |