From 527d59a5750cd819659c3332666d8caae3574a60 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 20 Mar 2026 14:00:45 -0700 Subject: [PATCH 1/4] Add desktop task create modal UI --- ...0260320-desktop-task-create-tiptap-plan.md | 61 +++++ .../TaskMarkdownRenderer.tsx | 63 ++++- .../components/TasksTopBar/TasksTopBar.tsx | 218 +++++++++-------- .../CreateTaskDialog/CreateTaskDialog.tsx | 224 ++++++++++++++++++ .../CreateTaskAssigneePicker.tsx | 126 ++++++++++ .../CreateTaskAssigneePicker/index.ts | 1 + .../CreateTaskPriorityPicker.tsx | 59 +++++ .../CreateTaskPriorityPicker/index.ts | 1 + .../CreateTaskStatusPicker.tsx | 76 ++++++ .../CreateTaskStatusPicker/index.ts | 1 + .../components/CreateTaskDialog/index.ts | 1 + 11 files changed, 721 insertions(+), 110 deletions(-) create mode 100644 apps/desktop/plans/20260320-desktop-task-create-tiptap-plan.md create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/CreateTaskAssigneePicker.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/CreateTaskPriorityPicker.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/CreateTaskStatusPicker.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/index.ts diff --git a/apps/desktop/plans/20260320-desktop-task-create-tiptap-plan.md b/apps/desktop/plans/20260320-desktop-task-create-tiptap-plan.md new file mode 100644 index 0000000000..a9114b2bf7 --- /dev/null +++ b/apps/desktop/plans/20260320-desktop-task-create-tiptap-plan.md @@ -0,0 +1,61 @@ +# Desktop Task Create: Linear-Style TipTap Plan + +## Goal + +Add a desktop-only create-task flow in the Tasks view that feels like Linear and uses the same editor surface as task editing. + +## Scope + +- Desktop only +- No web work +- No package extraction unless it becomes necessary later + +## Key Decision + +Create and edit should share one desktop task composer. Do not build a separate create modal with a second editor implementation. + +## Existing Anchors + +- Current task editor: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx` +- Bubble toolbar: `apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx` +- Tasks top bar entry point: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx` +- Existing metadata patterns: + - status: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx` + - priority: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/PriorityMenuItems.tsx` + - assignee: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/AssigneeFilter/AssigneeFilter.tsx` + +## Plan + +1. Extract a shared desktop `TaskComposer` under the desktop tasks feature. + Start from the current `TaskMarkdownRenderer`, bubble menu, and existing metadata controls. + +2. Migrate the existing task detail editor to `TaskComposer` in `edit` mode. + This keeps behavior aligned before adding create. + +3. Add a `CreateTaskDialog` launched from `TasksTopBar`. + The dialog should be compact and Linear-style: title first, metadata row, TipTap description, submit via `Cmd/Ctrl+Enter`. + +4. Add a desktop-facing create path that owns slug/default status resolution. + Do not change the existing low-level `task.create` contract in place if desktop/mobile sync depends on it. + +5. On successful create, close the dialog, refresh task data, and navigate to the new task. + +## Non-Goals for V1 + +- Web create flow +- Slash commands +- Image handling +- Full editor/package sharing outside the desktop task feature + +## Validation + +```bash +bun run typecheck +bun run lint +``` + +Manual checks: + +1. Create a task from the tasks top bar. +2. Edit an existing task with the refactored composer. +3. Confirm create and edit feel like the same surface. diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx index 0130a0a5a1..235ee01ddb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx @@ -1,6 +1,7 @@ import "highlight.js/styles/github-dark.css"; import "./task-markdown.css"; +import { cn } from "@superset/ui/utils"; import { Extension } from "@tiptap/core"; import { Blockquote } from "@tiptap/extension-blockquote"; import { Bold } from "@tiptap/extension-bold"; @@ -24,9 +25,15 @@ import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import { Text } from "@tiptap/extension-text"; import { Underline } from "@tiptap/extension-underline"; -import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react"; +import { + type Editor, + EditorContent, + ReactNodeViewRenderer, + useEditor, +} from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; import { common, createLowlight } from "lowlight"; +import { useEffect } from "react"; import { BubbleMenuToolbar } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar"; import { env } from "renderer/env.renderer"; import { Markdown } from "tiptap-markdown"; @@ -117,14 +124,34 @@ const KeyboardHandler = Extension.create({ interface TaskMarkdownRendererProps { content: string; - onSave: (markdown: string) => void; + onSave?: (markdown: string) => void; + onChange?: (markdown: string) => void; + placeholder?: string; + autoFocus?: boolean; + className?: string; + editorClassName?: string; + onModEnter?: () => void; +} + +function getMarkdown(editor: Editor | null): string { + const storage = editor?.storage as + | Record string }> + | undefined; + return storage?.markdown?.getMarkdown?.() ?? ""; } export function TaskMarkdownRenderer({ content, onSave, + onChange, + placeholder = "Add description...", + autoFocus = false, + className, + editorClassName, + onModEnter, }: TaskMarkdownRendererProps) { const editor = useEditor({ + autofocus: autoFocus ? "end" : false, extensions: [ Document, Text, @@ -198,7 +225,7 @@ export function TaskMarkdownRenderer({ Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "paragraph") { - return "Add description..."; + return placeholder; } return ""; }, @@ -217,21 +244,35 @@ export function TaskMarkdownRenderer({ content, editorProps: { attributes: { - class: "focus:outline-none min-h-[100px]", + class: cn("focus:outline-none min-h-[100px]", editorClassName), }, + handleKeyDown: (_, event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + onModEnter?.(); + return true; + } + return false; + }, + }, + onUpdate: ({ editor }) => { + onChange?.(getMarkdown(editor)); }, onBlur: ({ editor }) => { - const storage = editor.storage as unknown as Record< - string, - { getMarkdown?: () => string } - >; - const markdown = storage.markdown?.getMarkdown?.() ?? ""; - onSave(markdown); + onSave?.(getMarkdown(editor)); }, }); + useEffect(() => { + if (!editor || editor.isFocused) return; + + const currentMarkdown = getMarkdown(editor); + if (currentMarkdown === content) return; + + editor.commands.setContent(content, { emitUpdate: false }); + }, [content, editor]); + return ( -
+
{editor && ( (null); + const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false); useAppHotkey( "FOCUS_TASK_SEARCH", @@ -78,110 +81,127 @@ export function TasksTopBar({ const hasSelection = selectedCount > 0; return ( -
- {/* Left side: tabs/filters or selection actions */} -
- {hasSelection ? ( - <> - - - {selectedCount} selected - -
- {})} - /> - - ) : ( - <> - onTabChange(value as TabValue)} - > - - {TABS.map((tab) => { - const Icon = tab.Icon; - return ( - - - {tab.label} - - ); - })} - - + <> +
+ {/* Left side: tabs/filters or selection actions */} +
+ {hasSelection ? ( + <> + + + {selectedCount} selected + +
+ {})} + /> + + ) : ( + <> + onTabChange(value as TabValue)} + > + + {TABS.map((tab) => { + const Icon = tab.Icon; + return ( + + + {tab.label} + + ); + })} + + -
+
- - - )} -
+ + + )} +
- {/* Right side: view toggle + search */} -
-
- - -
+ + New task + + +
+ + +
-
- - onSearchChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - onSearchChange(""); - searchInputRef.current?.blur(); - } - }} - className="h-8 pl-9 pr-3 text-sm bg-muted/50 border-0 focus-visible:ring-1" - /> +
+ + onSearchChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + onSearchChange(""); + searchInputRef.current?.blur(); + } + }} + className="h-8 pl-9 pr-3 text-sm bg-muted/50 border-0 focus-visible:ring-1" + /> +
-
+ + + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx new file mode 100644 index 0000000000..b7ba74def2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx @@ -0,0 +1,224 @@ +import { authClient } from "@superset/auth/client"; +import type { TaskPriority } from "@superset/db/enums"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Kbd } from "@superset/ui/kbd"; +import { toast } from "@superset/ui/sonner"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { HiChevronRight, HiOutlinePaperClip, HiXMark } from "react-icons/hi2"; +import { TaskMarkdownRenderer } from "renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { compareStatusesForDropdown } from "../../../../utils/sorting"; +import { CreateTaskAssigneePicker } from "./components/CreateTaskAssigneePicker"; +import { CreateTaskPriorityPicker } from "./components/CreateTaskPriorityPicker"; +import { CreateTaskStatusPicker } from "./components/CreateTaskStatusPicker"; + +interface CreateTaskDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateTaskDialog({ + open, + onOpenChange, +}: CreateTaskDialogProps) { + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const titleInputRef = useRef(null); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [statusId, setStatusId] = useState(null); + const [priority, setPriority] = useState("none"); + const [assigneeId, setAssigneeId] = useState(null); + + const { data: statusData } = useLiveQuery( + (q) => + q + .from({ taskStatuses: collections.taskStatuses }) + .select(({ taskStatuses }) => ({ ...taskStatuses })), + [collections], + ); + + const { data: userData } = useLiveQuery( + (q) => + q + .from({ users: collections.users }) + .select(({ users }) => ({ ...users })), + [collections], + ); + const { data: organizationData } = useLiveQuery( + (q) => + q + .from({ organizations: collections.organizations }) + .select(({ organizations }) => ({ ...organizations })), + [collections], + ); + + const statuses = useMemo(() => statusData ?? [], [statusData]); + const users = useMemo(() => userData ?? [], [userData]); + const activeOrganizationId = session?.session?.activeOrganizationId ?? null; + const organizationLabel = useMemo(() => { + const organization = organizationData?.find( + (org) => org.id === activeOrganizationId, + ); + return organization?.name ?? "Task"; + }, [activeOrganizationId, organizationData]); + + const defaultStatusId = useMemo(() => { + const sortedStatuses = [...statuses].sort(compareStatusesForDropdown); + return ( + sortedStatuses.find((status) => status.type === "backlog")?.id ?? + sortedStatuses[0]?.id ?? + null + ); + }, [statuses]); + + useEffect(() => { + if (open && statusId === null && defaultStatusId) { + setStatusId(defaultStatusId); + } + }, [defaultStatusId, open, statusId]); + + useEffect(() => { + if (open) return; + + setTitle(""); + setDescription(""); + setStatusId(defaultStatusId); + setPriority("none"); + setAssigneeId(null); + }, [defaultStatusId, open]); + + const handleCreate = () => { + if (!title.trim()) return; + + toast.info("Create task persistence is next", { + description: + "The desktop create surface is in place; submit wiring is not yet connected.", + }); + }; + + const currentStatusType = useMemo( + () => statuses.find((status) => status.id === statusId)?.type, + [statusId, statuses], + ); + const handleAttachmentClick = () => { + toast.info("Attachments are not wired yet"); + }; + + return ( + + { + event.preventDefault(); + titleInputRef.current?.focus(); + }} + > + + Create Task + + Create a new task from the desktop tasks view. + + + +
+
+
+ {organizationLabel} +
+ + New issue +
+ + + + +
+ +
+ setTitle(event.target.value)} + placeholder="Task title" + className="w-full bg-transparent text-3xl font-semibold tracking-tight outline-none placeholder:text-muted-foreground/60" + /> + +
+ +
+ +
+ + + +
+
+ + + + +
+ Ctrl/⌘ + + + Enter + to create +
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/CreateTaskAssigneePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/CreateTaskAssigneePicker.tsx new file mode 100644 index 0000000000..1855d6b06f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/CreateTaskAssigneePicker.tsx @@ -0,0 +1,126 @@ +import type { SelectUser } from "@superset/db/schema"; +import { Avatar } from "@superset/ui/atoms/Avatar"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useMemo, useState } from "react"; +import { HiCheck, HiChevronDown, HiOutlineUserCircle } from "react-icons/hi2"; + +interface CreateTaskAssigneePickerProps { + users: SelectUser[]; + value: string | null; + onChange: (value: string | null) => void; +} + +export function CreateTaskAssigneePicker({ + users, + value, + onChange, +}: CreateTaskAssigneePickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const selectedUser = useMemo( + () => users.find((user) => user.id === value) ?? null, + [users, value], + ); + + const filteredUsers = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) return users; + + return users.filter((user) => { + return ( + user.name?.toLowerCase().includes(query) || + user.email?.toLowerCase().includes(query) + ); + }); + }, [search, users]); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setSearch(""); + } + }; + + const handleSelect = (nextValue: string | null) => { + onChange(nextValue); + setOpen(false); + setSearch(""); + }; + + return ( + + + + + + + + + + handleSelect(null)}> + + No assignee + {value === null && } + + + + {filteredUsers.length === 0 ? ( + No people found. + ) : ( + + {filteredUsers.map((user) => ( + handleSelect(user.id)} + > + +
+ {user.name} + + {user.email} + +
+ {user.id === value && } +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/index.ts new file mode 100644 index 0000000000..8a4778e73d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskAssigneePicker/index.ts @@ -0,0 +1 @@ +export { CreateTaskAssigneePicker } from "./CreateTaskAssigneePicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/CreateTaskPriorityPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/CreateTaskPriorityPicker.tsx new file mode 100644 index 0000000000..f2268f5960 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/CreateTaskPriorityPicker.tsx @@ -0,0 +1,59 @@ +import type { TaskPriority } from "@superset/db/enums"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { useState } from "react"; +import { HiChevronDown } from "react-icons/hi2"; +import { PriorityIcon } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/PriorityIcon"; +import { PriorityMenuItems } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/PriorityMenuItems"; + +const PRIORITY_LABELS: Record = { + none: "No priority", + urgent: "Urgent", + high: "High", + medium: "Medium", + low: "Low", +}; + +interface CreateTaskPriorityPickerProps { + value: TaskPriority; + statusType?: string; + onChange: (value: TaskPriority) => void; +} + +export function CreateTaskPriorityPicker({ + value, + statusType, + onChange, +}: CreateTaskPriorityPickerProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + { + onChange(priority); + setOpen(false); + }} + MenuItem={DropdownMenuItem} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/index.ts new file mode 100644 index 0000000000..f0a09791d0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskPriorityPicker/index.ts @@ -0,0 +1 @@ +export { CreateTaskPriorityPicker } from "./CreateTaskPriorityPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/CreateTaskStatusPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/CreateTaskStatusPicker.tsx new file mode 100644 index 0000000000..bb92a66696 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/CreateTaskStatusPicker.tsx @@ -0,0 +1,76 @@ +import type { SelectTaskStatus } from "@superset/db/schema"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { useMemo, useState } from "react"; +import { HiChevronDown } from "react-icons/hi2"; +import { + StatusIcon, + type StatusType, +} from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusIcon"; +import { StatusMenuItems } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusMenuItems"; +import { compareStatusesForDropdown } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/utils/sorting"; + +interface CreateTaskStatusPickerProps { + statuses: SelectTaskStatus[]; + value: string | null; + onChange: (value: string) => void; +} + +export function CreateTaskStatusPicker({ + statuses, + value, + onChange, +}: CreateTaskStatusPickerProps) { + const [open, setOpen] = useState(false); + + const currentStatus = useMemo( + () => statuses.find((status) => status.id === value) ?? null, + [statuses, value], + ); + + const sortedStatuses = useMemo( + () => [...statuses].sort(compareStatusesForDropdown), + [statuses], + ); + + return ( + + + + + + { + onChange(status.id); + setOpen(false); + }} + MenuItem={DropdownMenuItem} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/index.ts new file mode 100644 index 0000000000..2d8a6360d6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/components/CreateTaskStatusPicker/index.ts @@ -0,0 +1 @@ +export { CreateTaskStatusPicker } from "./CreateTaskStatusPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/index.ts new file mode 100644 index 0000000000..cf44a97b1f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/index.ts @@ -0,0 +1 @@ +export { CreateTaskDialog } from "./CreateTaskDialog"; From c82c3f9911b41f969cc5b358a033508f67c06b90 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 20 Mar 2026 16:03:19 -0700 Subject: [PATCH 2/4] feat(desktop): wire task create modal to task creation --- .../components/TasksTopBar/TasksTopBar.tsx | 3 + .../CreateTaskDialog/CreateTaskDialog.tsx | 102 +++++++++++++++--- bun.lock | 2 +- packages/trpc/src/router/task/schema.ts | 11 ++ packages/trpc/src/router/task/task.ts | 101 ++++++++++++++++- 5 files changed, 198 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx index 3b58ec6586..4b6585151c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx @@ -201,6 +201,9 @@ export function TasksTopBar({ ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx index b7ba74def2..07cf152ae2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx @@ -10,14 +10,18 @@ import { DialogHeader, DialogTitle, } from "@superset/ui/dialog"; -import { Kbd } from "@superset/ui/kbd"; +import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { toast } from "@superset/ui/sonner"; import { useLiveQuery } from "@tanstack/react-db"; +import { useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { HiChevronRight, HiOutlinePaperClip, HiXMark } from "react-icons/hi2"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { TaskMarkdownRenderer } from "renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useHotkeysStore } from "renderer/stores/hotkeys/store"; import { compareStatusesForDropdown } from "../../../../utils/sorting"; +import type { TabValue } from "../../TasksTopBar"; import { CreateTaskAssigneePicker } from "./components/CreateTaskAssigneePicker"; import { CreateTaskPriorityPicker } from "./components/CreateTaskPriorityPicker"; import { CreateTaskStatusPicker } from "./components/CreateTaskStatusPicker"; @@ -25,20 +29,30 @@ import { CreateTaskStatusPicker } from "./components/CreateTaskStatusPicker"; interface CreateTaskDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + currentTab: TabValue; + searchQuery: string; + assigneeFilter: string | null; } export function CreateTaskDialog({ open, onOpenChange, + currentTab, + searchQuery, + assigneeFilter, }: CreateTaskDialogProps) { const collections = useCollections(); const { data: session } = authClient.useSession(); + const navigate = useNavigate(); + const platform = useHotkeysStore((state) => state.platform); + const modKey = platform === "darwin" ? "⌘" : "Ctrl"; const titleInputRef = useRef(null); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [statusId, setStatusId] = useState(null); const [priority, setPriority] = useState("none"); const [assigneeId, setAssigneeId] = useState(null); + const [isCreating, setIsCreating] = useState(false); const { data: statusData } = useLiveQuery( (q) => @@ -96,15 +110,19 @@ export function CreateTaskDialog({ setStatusId(defaultStatusId); setPriority("none"); setAssigneeId(null); + setIsCreating(false); }, [defaultStatusId, open]); - const handleCreate = () => { - if (!title.trim()) return; + const waitForTaskSync = async (taskId: string) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (collections.tasks.get(taskId)) { + return; + } - toast.info("Create task persistence is next", { - description: - "The desktop create surface is in place; submit wiring is not yet connected.", - }); + await new Promise((resolve) => { + window.setTimeout(resolve, 100); + }); + } }; const currentStatusType = useMemo( @@ -114,6 +132,45 @@ export function CreateTaskDialog({ const handleAttachmentClick = () => { toast.info("Attachments are not wired yet"); }; + const handleCreate = async () => { + if (!title.trim() || isCreating) return; + + setIsCreating(true); + + try { + const result = await apiTrpcClient.task.createFromUi.mutate({ + title: title.trim(), + description: description.trim() || null, + statusId, + priority, + assigneeId, + }); + + if (!result.task) { + throw new Error("Task creation returned no task"); + } + + await waitForTaskSync(result.task.id); + + const nextSearch: Record = {}; + if (currentTab !== "all") nextSearch.tab = currentTab; + if (assigneeFilter) nextSearch.assignee = assigneeFilter; + if (searchQuery) nextSearch.search = searchQuery; + + onOpenChange(false); + toast.success(`Created ${result.task.slug}`); + navigate({ + to: "/tasks/$taskId", + params: { taskId: result.task.id }, + search: nextSearch, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to create task", + ); + setIsCreating(false); + } + }; return ( @@ -144,6 +201,7 @@ export function CreateTaskDialog({ -
- Ctrl/⌘ - + - Enter - to create -
-
diff --git a/bun.lock b/bun.lock index 20b0eac983..36eaa6439c 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.2.2", + "version": "1.2.3", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", diff --git a/packages/trpc/src/router/task/schema.ts b/packages/trpc/src/router/task/schema.ts index ee550732b4..09f0050b14 100644 --- a/packages/trpc/src/router/task/schema.ts +++ b/packages/trpc/src/router/task/schema.ts @@ -16,6 +16,17 @@ export const createTaskSchema = z.object({ labels: z.array(z.string()).nullish(), }); +export const createTaskFromUiSchema = z.object({ + title: z.string().min(1), + description: z.string().nullish(), + statusId: z.string().uuid().nullish(), + priority: z.enum(taskPriorityValues).default("none"), + assigneeId: z.string().uuid().nullish(), + estimate: z.number().int().positive().nullish(), + dueDate: z.coerce.date().nullish(), + labels: z.array(z.string()).nullish(), +}); + export const updateTaskSchema = z.object({ id: z.string().uuid(), title: z.string().min(1).optional(), diff --git a/packages/trpc/src/router/task/task.ts b/packages/trpc/src/router/task/task.ts index f7e1db36bb..af937072c1 100644 --- a/packages/trpc/src/router/task/task.ts +++ b/packages/trpc/src/router/task/task.ts @@ -1,13 +1,61 @@ import { db, dbWs } from "@superset/db/client"; import { tasks, users } from "@superset/db/schema"; +import { seedDefaultStatuses } from "@superset/db/seed-default-statuses"; import { getCurrentTxid } from "@superset/db/utils"; -import type { TRPCRouterRecord } from "@trpc/server"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; +import { and, desc, eq, ilike, isNull, or } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { z } from "zod"; import { syncTask } from "../../lib/integrations/sync"; import { protectedProcedure, publicProcedure } from "../../trpc"; -import { createTaskSchema, updateTaskSchema } from "./schema"; +import { + createTaskFromUiSchema, + createTaskSchema, + updateTaskSchema, +} from "./schema"; + +function generateBaseSlug(title: string): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + + return slug || "task"; +} + +async function generateUniqueTaskSlug( + organizationId: string, + title: string, +): Promise { + const baseSlug = generateBaseSlug(title); + + const existingTasks = await db + .select({ slug: tasks.slug }) + .from(tasks) + .where( + and( + eq(tasks.organizationId, organizationId), + isNull(tasks.deletedAt), + or(ilike(tasks.slug, `${baseSlug}%`)), + ), + ); + + const usedSlugs = new Set(existingTasks.map((task) => task.slug)); + + if (!usedSlugs.has(baseSlug)) { + return baseSlug; + } + + let counter = 1; + let slug = `${baseSlug}-${counter}`; + while (usedSlugs.has(slug)) { + counter += 1; + slug = `${baseSlug}-${counter}`; + } + + return slug; +} export const taskRouter = { all: publicProcedure.query(() => { @@ -88,6 +136,53 @@ export const taskRouter = { return result; }), + createFromUi: protectedProcedure + .input(createTaskFromUiSchema) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + + if (!organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "No active organization selected", + }); + } + + const slug = await generateUniqueTaskSlug(organizationId, input.title); + + const result = await dbWs.transaction(async (tx) => { + const statusId = + input.statusId ?? (await seedDefaultStatuses(organizationId, tx)); + + const [task] = await tx + .insert(tasks) + .values({ + slug, + title: input.title, + description: input.description ?? null, + statusId, + priority: input.priority ?? "none", + organizationId, + creatorId: ctx.session.user.id, + assigneeId: input.assigneeId ?? null, + estimate: input.estimate ?? null, + dueDate: input.dueDate ?? null, + labels: input.labels ?? [], + }) + .returning(); + + const txid = await getCurrentTxid(tx); + + return { task, txid }; + }); + + if (result.task) { + syncTask(result.task.id); + } + + return result; + }), + update: protectedProcedure .input(updateTaskSchema) .mutation(async ({ input }) => { From 04c1542e701c684111d4b9d61923ac0725a770e2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 20 Mar 2026 16:27:17 -0700 Subject: [PATCH 3/4] fix(tasks): harden desktop task create flow --- .../CreateTaskDialog/CreateTaskDialog.tsx | 14 -- .../tools/tasks/create-task/create-task.ts | 30 +-- packages/shared/package.json | 4 + packages/shared/src/task-slug.test.ts | 30 +++ packages/shared/src/task-slug.ts | 30 +++ packages/trpc/src/router/task/task.ts | 185 +++++++++++------- 6 files changed, 185 insertions(+), 108 deletions(-) create mode 100644 packages/shared/src/task-slug.test.ts create mode 100644 packages/shared/src/task-slug.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx index 07cf152ae2..e7b38af2f3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx @@ -113,18 +113,6 @@ export function CreateTaskDialog({ setIsCreating(false); }, [defaultStatusId, open]); - const waitForTaskSync = async (taskId: string) => { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (collections.tasks.get(taskId)) { - return; - } - - await new Promise((resolve) => { - window.setTimeout(resolve, 100); - }); - } - }; - const currentStatusType = useMemo( () => statuses.find((status) => status.id === statusId)?.type, [statusId, statuses], @@ -150,8 +138,6 @@ export function CreateTaskDialog({ throw new Error("Task creation returned no task"); } - await waitForTaskSync(result.task.id); - const nextSearch: Record = {}; if (currentTab !== "all") nextSearch.tab = currentTab; if (assigneeFilter) nextSearch.assignee = assigneeFilter; diff --git a/packages/mcp/src/tools/tasks/create-task/create-task.ts b/packages/mcp/src/tools/tasks/create-task/create-task.ts index 0db73cefcf..c3d9a73b3e 100644 --- a/packages/mcp/src/tools/tasks/create-task/create-task.ts +++ b/packages/mcp/src/tools/tasks/create-task/create-task.ts @@ -2,6 +2,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { db, dbWs } from "@superset/db/client"; import { tasks } from "@superset/db/schema"; import { seedDefaultStatuses } from "@superset/db/seed-default-statuses"; +import { + generateBaseTaskSlug, + generateUniqueTaskSlug, +} from "@superset/shared/task-slug"; import { and, eq, ilike, or } from "drizzle-orm"; import { z } from "zod"; import { getMcpContext } from "../../utils"; @@ -38,28 +42,6 @@ const taskInputSchema = z.object({ type TaskInput = z.infer; -function generateBaseSlug(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 50); -} - -function generateUniqueSlug( - baseSlug: string, - existingSlugs: Set, -): string { - let slug = baseSlug; - if (existingSlugs.has(slug)) { - let counter = 1; - while (existingSlugs.has(slug)) { - slug = `${baseSlug}-${counter++}`; - } - } - return slug; -} - export function register(server: McpServer) { server.registerTool( "create_task", @@ -93,7 +75,7 @@ export function register(server: McpServer) { defaultStatusId = await seedDefaultStatuses(ctx.organizationId); } - const baseSlugs = taskInputs.map((t) => generateBaseSlug(t.title)); + const baseSlugs = taskInputs.map((t) => generateBaseTaskSlug(t.title)); const uniqueBaseSlugs = [...new Set(baseSlugs)]; const slugConditions = uniqueBaseSlugs.map((baseSlug) => @@ -131,7 +113,7 @@ export function register(server: McpServer) { for (const [i, input] of taskInputs.entries()) { const baseSlug = baseSlugs[i] ?? ""; - const slug = generateUniqueSlug(baseSlug, usedSlugs); + const slug = generateUniqueTaskSlug(baseSlug, usedSlugs); usedSlugs.add(slug); const priority: TaskPriority = isPriority(input.priority) diff --git a/packages/shared/package.json b/packages/shared/package.json index 44c2369171..19d5bc1d35 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,6 +36,10 @@ "types": "./src/agent-prompt-template.ts", "default": "./src/agent-prompt-template.ts" }, + "./task-slug": { + "types": "./src/task-slug.ts", + "default": "./src/task-slug.ts" + }, "./claude-command": { "types": "./src/claude-command.ts", "default": "./src/claude-command.ts" diff --git a/packages/shared/src/task-slug.test.ts b/packages/shared/src/task-slug.test.ts new file mode 100644 index 0000000000..ba17133bab --- /dev/null +++ b/packages/shared/src/task-slug.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import { generateBaseTaskSlug, generateUniqueTaskSlug } from "./task-slug"; + +describe("generateBaseTaskSlug", () => { + it("normalizes titles into lowercase kebab-case slugs", () => { + expect(generateBaseTaskSlug("Fix Linear Sync!")).toBe("fix-linear-sync"); + }); + + it("falls back to task when the title has no slug characters", () => { + expect(generateBaseTaskSlug("!!!")).toBe("task"); + }); +}); + +describe("generateUniqueTaskSlug", () => { + it("returns the base slug when unused", () => { + expect(generateUniqueTaskSlug("fix-linear-sync", [])).toBe( + "fix-linear-sync", + ); + }); + + it("increments numeric suffixes until it finds a free slug", () => { + expect( + generateUniqueTaskSlug("fix-linear-sync", [ + "fix-linear-sync", + "fix-linear-sync-1", + "fix-linear-sync-2", + ]), + ).toBe("fix-linear-sync-3"); + }); +}); diff --git a/packages/shared/src/task-slug.ts b/packages/shared/src/task-slug.ts new file mode 100644 index 0000000000..ae72ffd884 --- /dev/null +++ b/packages/shared/src/task-slug.ts @@ -0,0 +1,30 @@ +export function generateBaseTaskSlug(title: string): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + + return slug || "task"; +} + +export function generateUniqueTaskSlug( + baseSlug: string, + existingSlugs: Iterable, +): string { + const usedSlugs = new Set(existingSlugs); + + if (!usedSlugs.has(baseSlug)) { + return baseSlug; + } + + let counter = 1; + let slug = `${baseSlug}-${counter}`; + + while (usedSlugs.has(slug)) { + counter += 1; + slug = `${baseSlug}-${counter}`; + } + + return slug; +} diff --git a/packages/trpc/src/router/task/task.ts b/packages/trpc/src/router/task/task.ts index af937072c1..f15101fe89 100644 --- a/packages/trpc/src/router/task/task.ts +++ b/packages/trpc/src/router/task/task.ts @@ -1,9 +1,13 @@ import { db, dbWs } from "@superset/db/client"; -import { tasks, users } from "@superset/db/schema"; +import { members, taskStatuses, tasks, users } from "@superset/db/schema"; import { seedDefaultStatuses } from "@superset/db/seed-default-statuses"; import { getCurrentTxid } from "@superset/db/utils"; +import { + generateBaseTaskSlug, + generateUniqueTaskSlug, +} from "@superset/shared/task-slug"; import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; -import { and, desc, eq, ilike, isNull, or } from "drizzle-orm"; +import { and, desc, eq, ilike, isNull } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { z } from "zod"; import { syncTask } from "../../lib/integrations/sync"; @@ -14,47 +18,16 @@ import { updateTaskSchema, } from "./schema"; -function generateBaseSlug(title: string): string { - const slug = title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 50); - - return slug || "task"; -} +const TASK_SLUG_CONSTRAINT = "tasks_org_slug_unique"; +const TASK_SLUG_RETRY_LIMIT = 5; -async function generateUniqueTaskSlug( - organizationId: string, - title: string, -): Promise { - const baseSlug = generateBaseSlug(title); - - const existingTasks = await db - .select({ slug: tasks.slug }) - .from(tasks) - .where( - and( - eq(tasks.organizationId, organizationId), - isNull(tasks.deletedAt), - or(ilike(tasks.slug, `${baseSlug}%`)), - ), - ); - - const usedSlugs = new Set(existingTasks.map((task) => task.slug)); - - if (!usedSlugs.has(baseSlug)) { - return baseSlug; +function isConstraintError(error: unknown, constraint: string): boolean { + if (!error || typeof error !== "object") { + return false; } - let counter = 1; - let slug = `${baseSlug}-${counter}`; - while (usedSlugs.has(slug)) { - counter += 1; - slug = `${baseSlug}-${counter}`; - } - - return slug; + const maybeError = error as { code?: string; constraint?: string }; + return maybeError.code === "23505" && maybeError.constraint === constraint; } export const taskRouter = { @@ -148,39 +121,111 @@ export const taskRouter = { }); } - const slug = await generateUniqueTaskSlug(organizationId, input.title); - - const result = await dbWs.transaction(async (tx) => { - const statusId = - input.statusId ?? (await seedDefaultStatuses(organizationId, tx)); - - const [task] = await tx - .insert(tasks) - .values({ - slug, - title: input.title, - description: input.description ?? null, - statusId, - priority: input.priority ?? "none", - organizationId, - creatorId: ctx.session.user.id, - assigneeId: input.assigneeId ?? null, - estimate: input.estimate ?? null, - dueDate: input.dueDate ?? null, - labels: input.labels ?? [], - }) - .returning(); - - const txid = await getCurrentTxid(tx); - - return { task, txid }; - }); + for (let attempt = 0; attempt < TASK_SLUG_RETRY_LIMIT; attempt += 1) { + try { + const result = await dbWs.transaction(async (tx) => { + const statusId = input.statusId + ? ( + await tx + .select({ id: taskStatuses.id }) + .from(taskStatuses) + .where( + and( + eq(taskStatuses.id, input.statusId), + eq(taskStatuses.organizationId, organizationId), + ), + ) + .limit(1) + )[0]?.id + : await seedDefaultStatuses(organizationId, tx); + + if (!statusId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Status must belong to the active organization", + }); + } + + const assigneeId = input.assigneeId + ? (( + await tx + .select({ userId: members.userId }) + .from(members) + .where( + and( + eq(members.organizationId, organizationId), + eq(members.userId, input.assigneeId), + ), + ) + .limit(1) + )[0]?.userId ?? null) + : null; + + if (input.assigneeId && !assigneeId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Assignee must belong to the active organization", + }); + } + + const baseSlug = generateBaseTaskSlug(input.title); + const existingSlugs = await tx + .select({ slug: tasks.slug }) + .from(tasks) + .where( + and( + eq(tasks.organizationId, organizationId), + ilike(tasks.slug, `${baseSlug}%`), + ), + ); + const slug = generateUniqueTaskSlug( + baseSlug, + existingSlugs.map((task) => task.slug), + ); + + const [task] = await tx + .insert(tasks) + .values({ + slug, + title: input.title, + description: input.description ?? null, + statusId, + priority: input.priority ?? "none", + organizationId, + creatorId: ctx.session.user.id, + assigneeId, + estimate: input.estimate ?? null, + dueDate: input.dueDate ?? null, + labels: input.labels ?? [], + }) + .returning(); + + const txid = await getCurrentTxid(tx); + + return { task, txid }; + }); - if (result.task) { - syncTask(result.task.id); + if (result.task) { + syncTask(result.task.id); + } + + return result; + } catch (error) { + if ( + isConstraintError(error, TASK_SLUG_CONSTRAINT) && + attempt < TASK_SLUG_RETRY_LIMIT - 1 + ) { + continue; + } + + throw error; + } } - return result; + throw new TRPCError({ + code: "CONFLICT", + message: "Failed to generate a unique task slug", + }); }), update: protectedProcedure From 7f2dbe5388ba34b02cf42f6e5f2c911ccba9370d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 20 Mar 2026 16:27:31 -0700 Subject: [PATCH 4/4] fix(desktop): show task sync state after create --- .../_dashboard/tasks/$taskId/page.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx index 428ddc2549..0b27c01af6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx @@ -3,10 +3,12 @@ import { ScrollArea } from "@superset/ui/scroll-area"; import { Separator } from "@superset/ui/separator"; import { eq, or } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; +import { useQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; import { HiArrowLeft } from "react-icons/hi2"; import { LuExternalLink } from "react-icons/lu"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { TaskWithStatus } from "../components/TasksView/hooks/useTasksTable"; import { Route as TasksLayoutRoute } from "../layout"; @@ -28,6 +30,10 @@ function TaskDetailPage() { const { tab, assignee, search } = TasksLayoutRoute.useSearch(); const navigate = useNavigate(); const collections = useCollections(); + const isUuidTaskId = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + taskId, + ); const backSearch = useMemo(() => { const s: Record = {}; @@ -62,6 +68,17 @@ function TaskDetailPage() { if (!taskData || taskData.length === 0) return null; return taskData[0]; }, [taskData]); + const taskFallbackQuery = useQuery({ + queryKey: ["task-detail-fallback", taskId, isUuidTaskId ? "id" : "slug"], + queryFn: () => + isUuidTaskId + ? apiTrpcClient.task.byId.query(taskId) + : apiTrpcClient.task.bySlug.query(taskId), + enabled: !task, + retry: false, + }); + const isTaskSyncing = !task && !!taskFallbackQuery.data; + const isTaskLoading = !task && taskFallbackQuery.isPending; const handleBack = () => { navigate({ to: "/tasks", search: backSearch }); @@ -82,6 +99,16 @@ function TaskDetailPage() { }; if (!task) { + if (isTaskLoading || isTaskSyncing) { + return ( +
+ + {isTaskSyncing ? "Syncing task..." : "Loading task..."} + +
+ ); + } + return (
Task not found