diff --git a/apps/desktop/src/renderer/commandPalette/ui/LinkTask/LinkTaskFrame.tsx b/apps/desktop/src/renderer/commandPalette/ui/LinkTask/LinkTaskFrame.tsx index ff42f368a3a..e3c70fd7870 100644 --- a/apps/desktop/src/renderer/commandPalette/ui/LinkTask/LinkTaskFrame.tsx +++ b/apps/desktop/src/renderer/commandPalette/ui/LinkTask/LinkTaskFrame.tsx @@ -6,14 +6,36 @@ import { } from "@superset/ui/command"; import { toast } from "@superset/ui/sonner"; import { useLiveQuery } from "@tanstack/react-db"; -import Fuse from "fuse.js"; -import { useMemo } from "react"; +import { useDeferredValue, useMemo } from "react"; +import { + StatusIcon, + type StatusType, +} from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusIcon"; +import { useHybridSearch } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useFrameStackStore } from "../../core/frames"; import { useCommandPaletteQuery } from "../CommandPalette/CommandPalette"; const MAX_RESULTS = 25; +// Matches tasks list view ordering: in progress → todo → backlog → done → canceled. +const STATUS_TYPE_ORDER: Record = { + started: 0, + unstarted: 1, + backlog: 2, + completed: 3, + canceled: 4, +}; + +const PRIORITY_ORDER: Record = { + urgent: 0, + high: 1, + medium: 2, + low: 3, + none: 4, +}; + interface LinkTaskFrameProps { workspaceId: string; } @@ -21,7 +43,9 @@ interface LinkTaskFrameProps { export function LinkTaskFrame({ workspaceId }: LinkTaskFrameProps) { const collections = useCollections(); const query = useCommandPaletteQuery(); + const deferredQuery = useDeferredValue(query); const setOpen = useFrameStackStore((s) => s.setOpen); + const { v2Workspaces } = useOptimisticCollectionActions(); const { data: tasks = [] } = useLiveQuery( (q) => @@ -29,68 +53,130 @@ export function LinkTaskFrame({ workspaceId }: LinkTaskFrameProps) { id: t.id, slug: t.slug, title: t.title, + description: t.description, + labels: t.labels, + statusId: t.statusId, + priority: t.priority, externalUrl: t.externalUrl, updatedAt: t.updatedAt, })), [collections.tasks], ); - const fuse = useMemo( - () => - new Fuse(tasks, { - keys: [ - { name: "slug", weight: 3 }, - { name: "title", weight: 2 }, - ], - threshold: 0.4, - ignoreLocation: true, - }), - [tasks], + const { data: statuses = [] } = useLiveQuery( + (q) => + q.from({ s: collections.taskStatuses }).select(({ s }) => ({ + id: s.id, + type: s.type, + color: s.color, + position: s.position, + progressPercent: s.progressPercent, + })), + [collections.taskStatuses], ); + const statusMap = useMemo(() => { + const map = new Map< + string, + { + type: StatusType; + color: string; + position: number; + progressPercent: number | null; + } + >(); + for (const s of statuses) { + map.set(s.id, { + type: s.type as StatusType, + color: s.color, + position: s.position, + progressPercent: s.progressPercent, + }); + } + return map; + }, [statuses]); + + const { search } = useHybridSearch(tasks); + const filtered = useMemo(() => { - if (!query) { + if (!deferredQuery) { return [...tasks] - .sort( - (a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), - ) + .sort((a, b) => { + const statusA = a.statusId ? statusMap.get(a.statusId) : undefined; + const statusB = b.statusId ? statusMap.get(b.statusId) : undefined; + const typeOrderA = + STATUS_TYPE_ORDER[statusA?.type ?? ""] ?? Number.MAX_SAFE_INTEGER; + const typeOrderB = + STATUS_TYPE_ORDER[statusB?.type ?? ""] ?? Number.MAX_SAFE_INTEGER; + if (typeOrderA !== typeOrderB) return typeOrderA - typeOrderB; + const positionA = statusA?.position ?? Number.MAX_SAFE_INTEGER; + const positionB = statusB?.position ?? Number.MAX_SAFE_INTEGER; + if (positionA !== positionB) return positionA - positionB; + const priorityOrderA = + PRIORITY_ORDER[a.priority] ?? Number.MAX_SAFE_INTEGER; + const priorityOrderB = + PRIORITY_ORDER[b.priority] ?? Number.MAX_SAFE_INTEGER; + return priorityOrderA - priorityOrderB; + }) .slice(0, MAX_RESULTS); } - return fuse.search(query, { limit: MAX_RESULTS }).map((r) => r.item); - }, [query, fuse, tasks]); + return search(deferredQuery) + .slice(0, MAX_RESULTS) + .map((r) => r.item); + }, [deferredQuery, search, tasks, statusMap]); const handleSelect = (taskId: string, slug: string) => { + v2Workspaces.updateWorkspace(workspaceId, { taskId }); toast.success(`Linked ${slug} to workspace`); - void linkTaskToWorkspace(taskId, workspaceId); setOpen(false); }; return ( No tasks found. - - {filtered.map((task) => ( - handleSelect(task.id, task.slug)} - > - - {task.slug} - - {task.title} - - ))} - + {filtered.length > 0 && ( + + {filtered.map((task) => { + const status = task.statusId + ? statusMap.get(task.statusId) + : undefined; + return ( + handleSelect(task.id, task.slug)} + className="group items-start gap-3 rounded-md px-2.5 py-2" + > + + {status ? ( + + ) : ( + + )} + +
+ + {task.title} + + + {task.slug} + {status ? ( + <> + · + {status.type} + + ) : null} + +
+
+ ); + })} +
+ )}
); } - -async function linkTaskToWorkspace( - taskId: string, - workspaceId: string, -): Promise { - void taskId; - void workspaceId; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx index e3dffa07be2..c8a7d3a0eb9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx @@ -13,6 +13,7 @@ import { useHotkeyDisplay } from "renderer/hotkeys"; import type { DashboardSidebarWorkspace } from "../../../../types"; import { ChecksList } from "./components/ChecksList"; import { ChecksSummary } from "./components/ChecksSummary"; +import { LinkedTaskSection } from "./components/LinkedTaskSection"; import { PullRequestStatusBadge } from "./components/PullRequestStatusBadge"; import { ReviewStatus } from "./components/ReviewStatus"; @@ -37,6 +38,7 @@ export function DashboardSidebarWorkspaceHoverCardContent({ needsRebase, behindCount, createdAt, + taskId, } = workspace; const { keys: openPRDisplay } = useHotkeyDisplay("OPEN_PR"); const hasOpenPRShortcut = !( @@ -103,6 +105,8 @@ export function DashboardSidebarWorkspaceHoverCardContent({ + {taskId && } + {needsRebase && (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/LinkedTaskSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/LinkedTaskSection.tsx new file mode 100644 index 00000000000..af96db05584 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/LinkedTaskSection.tsx @@ -0,0 +1,84 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { Link } from "@tanstack/react-router"; +import { LuExternalLink } from "react-icons/lu"; +import { + StatusIcon, + type StatusType, +} from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusIcon"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +interface LinkedTaskSectionProps { + taskId: string; +} + +export function LinkedTaskSection({ taskId }: LinkedTaskSectionProps) { + const collections = useCollections(); + + const { data: rows = [] } = useLiveQuery( + (q) => + q + .from({ t: collections.tasks }) + .leftJoin({ s: collections.taskStatuses }, ({ t, s }) => + eq(t.statusId, s.id), + ) + .where(({ t }) => eq(t.id, taskId)) + .select(({ t, s }) => ({ + id: t.id, + slug: t.slug, + title: t.title, + externalUrl: t.externalUrl, + statusType: s?.type ?? null, + statusColor: s?.color ?? null, + statusProgress: s?.progressPercent ?? null, + })), + [collections, taskId], + ); + + const task = rows[0]; + if (!task) return null; + + return ( +
+ + Task + +
+ + + {task.statusType ? ( + + ) : ( + + )} + + + {task.slug} + + {task.title} + + {task.externalUrl && ( + e.stopPropagation()} + > + + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/index.ts new file mode 100644 index 00000000000..434e5052d33 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/components/LinkedTaskSection/index.ts @@ -0,0 +1 @@ +export { LinkedTaskSection } from "./LinkedTaskSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 9618b1ba469..11c29c8bc36 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -250,6 +250,7 @@ export function useDashboardSidebarData() { hostIsOnline: hosts.isOnline, name: workspaces.name, branch: workspaces.branch, + taskId: workspaces.taskId, createdAt: workspaces.createdAt, updatedAt: workspaces.updatedAt, tabOrder: sidebarWorkspaces.sidebarState.tabOrder, @@ -285,6 +286,7 @@ export function useDashboardSidebarData() { hostIsOnline: hosts.isOnline, name: workspaces.name, branch: workspaces.branch, + taskId: workspaces.taskId, createdAt: workspaces.createdAt, updatedAt: workspaces.updatedAt, tabOrder: MAIN_WORKSPACE_TAB_ORDER, @@ -319,6 +321,7 @@ export function useDashboardSidebarData() { hostIsOnline: host?.isOnline ?? false, name: cloudRow.name, branch: cloudRow.branch, + taskId: cloudRow.taskId, createdAt: cloudRow.createdAt, updatedAt: cloudRow.updatedAt, tabOrder: @@ -491,6 +494,7 @@ export function useDashboardSidebarData() { behindCount: null, createdAt: workspace.createdAt, updatedAt: workspace.updatedAt, + taskId: workspace.taskId, }; if (workspace.sectionId) { @@ -541,6 +545,7 @@ export function useDashboardSidebarData() { behindCount: null, createdAt: new Date(), updatedAt: new Date(), + taskId: null, creationStatus: pw.status, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index f2e5abcd060..c9be0b9edd1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -40,6 +40,7 @@ export interface DashboardSidebarWorkspace { behindCount: number | null; createdAt: Date; updatedAt: Date; + taskId: string | null; creationStatus?: "preparing" | "generating-branch" | "creating" | "failed"; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx index 9e8e30879b7..422796a1d2f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx @@ -1,6 +1,12 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useRef, + useState, +} from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useTasksFilterStore } from "../../stores/tasks-filter-state"; import { BoardContent } from "./components/BoardContent"; @@ -30,6 +36,7 @@ export function TasksView({ const collections = useCollections(); const currentTab: TabValue = initialTab ?? "all"; const [searchQuery, setSearchQuery] = useState(initialSearch ?? ""); + const deferredSearchQuery = useDeferredValue(searchQuery); const assigneeFilter = initialAssignee ?? null; const typeTab = initialType ?? "tasks"; const projectFilter = initialProject ?? null; @@ -231,14 +238,14 @@ export function TasksView({ (viewMode === "board" ? ( ) : ( )} {showIssues && ( )}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts index a2ff921ca89..673a1623c4b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts @@ -21,6 +21,7 @@ interface V2WorkspacePatch { name?: string; branch?: string; hostId?: string; + taskId?: string | null; } function getErrorMessage(error: unknown): string { @@ -189,6 +190,9 @@ export function useOptimisticCollectionActions() { if (patch.hostId !== undefined) { draft.hostId = patch.hostId; } + if (patch.taskId !== undefined) { + draft.taskId = patch.taskId; + } }), ), renameWorkspace: (workspaceId: string, name: string) => diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index 426001c0de7..f9be670e241 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -411,12 +411,13 @@ function createOrgCollections(organizationId: string): OrgCollections { getKey: (item) => item.id, onUpdate: async ({ transaction }) => { const { original, changes } = transaction.mutations[0]; - const { branch, hostId, name } = changes; + const { branch, hostId, name, taskId } = changes; const result = await apiClient.v2Workspace.update.mutate({ id: original.id, branch, hostId, name, + taskId, }); return { txid: result.txid }; }, diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index d346ed0a3eb..621d6514130 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -396,6 +396,7 @@ export const v2WorkspaceRouter = { name: z.string().min(1).optional(), branch: z.string().min(1).optional(), hostId: z.string().min(1).optional(), + taskId: z.string().uuid().nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -412,10 +413,30 @@ export const v2WorkspaceRouter = { await getScopedHost(workspace.organizationId, input.hostId); } + if (input.taskId) { + const found = await dbWs.query.tasks.findFirst({ + columns: { id: true, organizationId: true }, + where: eq(tasks.id, input.taskId), + }); + if (!found) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "taskId not found", + }); + } + if (found.organizationId !== workspace.organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "taskId must belong to the workspace's organization", + }); + } + } + const data = { branch: input.branch, hostId: input.hostId, name: input.name, + taskId: input.taskId, }; if ( Object.keys(data).every(