diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 77631f97a..8f25ded53 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -45,6 +45,10 @@ import type { CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse, + PinnedItem, + CreatePinRequest, + PinnedItemType, + ReorderPinsRequest, } from "../types"; import { type Logger, noopLogger } from "../logger"; @@ -693,4 +697,27 @@ export class ApiClient { async deleteProject(id: string): Promise { await this.fetch(`/api/projects/${id}`, { method: "DELETE" }); } + + // Pins + async listPins(): Promise { + return this.fetch("/api/pins"); + } + + async createPin(data: CreatePinRequest): Promise { + return this.fetch("/api/pins", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async deletePin(itemType: PinnedItemType, itemId: string): Promise { + await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" }); + } + + async reorderPins(data: ReorderPinsRequest): Promise { + await this.fetch("/api/pins/reorder", { + method: "PUT", + body: JSON.stringify(data), + }); + } } diff --git a/packages/core/package.json b/packages/core/package.json index 373a9208a..22ed5246a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,6 +45,9 @@ "./projects/queries": "./projects/queries.ts", "./projects/mutations": "./projects/mutations.ts", "./projects/config": "./projects/config.ts", + "./pins": "./pins/index.ts", + "./pins/queries": "./pins/queries.ts", + "./pins/mutations": "./pins/mutations.ts", "./realtime": "./realtime/index.ts", "./navigation": "./navigation/index.ts", "./modals": "./modals/index.ts", diff --git a/packages/core/pins/index.ts b/packages/core/pins/index.ts new file mode 100644 index 000000000..8676b8733 --- /dev/null +++ b/packages/core/pins/index.ts @@ -0,0 +1,2 @@ +export { pinKeys, pinListOptions } from "./queries"; +export { useCreatePin, useDeletePin, useReorderPins } from "./mutations"; diff --git a/packages/core/pins/mutations.ts b/packages/core/pins/mutations.ts new file mode 100644 index 000000000..be321f7b2 --- /dev/null +++ b/packages/core/pins/mutations.ts @@ -0,0 +1,65 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { pinKeys } from "./queries"; +import { useWorkspaceId } from "../hooks"; +import type { PinnedItem, PinnedItemType } from "../types"; + +export function useCreatePin() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (data: { item_type: PinnedItemType; item_id: string }) => + api.createPin(data), + onSuccess: (newPin) => { + qc.setQueryData(pinKeys.list(wsId), (old) => + old ? [...old, newPin] : [newPin], + ); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: pinKeys.list(wsId) }); + }, + }); +} + +export function useDeletePin() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) => + api.deletePin(itemType, itemId), + onMutate: async ({ itemType, itemId }) => { + await qc.cancelQueries({ queryKey: pinKeys.list(wsId) }); + const prev = qc.getQueryData(pinKeys.list(wsId)); + qc.setQueryData(pinKeys.list(wsId), (old) => + old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old, + ); + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: pinKeys.list(wsId) }); + }, + }); +} + +export function useReorderPins() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (reorderedPins: PinnedItem[]) => { + const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 })); + return api.reorderPins({ items }); + }, + onMutate: async (reorderedPins) => { + await qc.cancelQueries({ queryKey: pinKeys.list(wsId) }); + const prev = qc.getQueryData(pinKeys.list(wsId)); + qc.setQueryData(pinKeys.list(wsId), reorderedPins); + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev); + }, + }); +} diff --git a/packages/core/pins/queries.ts b/packages/core/pins/queries.ts new file mode 100644 index 000000000..2fece6a89 --- /dev/null +++ b/packages/core/pins/queries.ts @@ -0,0 +1,14 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "../api"; + +export const pinKeys = { + all: (wsId: string) => ["pins", wsId] as const, + list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const, +}; + +export function pinListOptions(wsId: string) { + return queryOptions({ + queryKey: pinKeys.list(wsId), + queryFn: () => api.listPins(), + }); +} diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index f4c02aec7..963299381 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -11,6 +11,7 @@ import { clearWorkspaceStorage } from "../platform/storage-cleanup"; import { defaultStorage } from "../platform/storage"; import { issueKeys } from "../issues/queries"; import { projectKeys } from "../projects/queries"; +import { pinKeys } from "../pins/queries"; import { runtimeKeys } from "../runtimes/queries"; import { onIssueCreated, @@ -99,6 +100,10 @@ export function useRealtimeSync( const wsId = workspaceStore.getState().workspace?.id; if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) }); }, + pin: () => { + const wsId = workspaceStore.getState().workspace?.id; + if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) }); + }, daemon: () => { const wsId = workspaceStore.getState().workspace?.id; if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) }); diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index 07bca0d5e..2f21527fa 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -50,7 +50,9 @@ export type WSEventType = | "chat:done" | "project:created" | "project:updated" - | "project:deleted"; + | "project:deleted" + | "pin:created" + | "pin:deleted"; export interface WSMessage { type: WSEventType; diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index b66f54f31..e6f4cd9bd 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -33,3 +33,4 @@ export type { Attachment } from "./attachment"; export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat"; export type { StorageAdapter } from "./storage"; export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project"; +export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin"; diff --git a/packages/core/types/pin.ts b/packages/core/types/pin.ts new file mode 100644 index 000000000..d67e77124 --- /dev/null +++ b/packages/core/types/pin.ts @@ -0,0 +1,24 @@ +export type PinnedItemType = "issue" | "project"; + +export interface PinnedItem { + id: string; + workspace_id: string; + user_id: string; + item_type: PinnedItemType; + item_id: string; + position: number; + created_at: string; + title: string; + identifier?: string; + icon?: string; + status?: string; +} + +export interface CreatePinRequest { + item_type: PinnedItemType; + item_id: string; +} + +export interface ReorderPinsRequest { + items: { id: string; position: number }[]; +} diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 87e509d05..7eef59ea4 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -12,6 +12,8 @@ import { Link2, MoreHorizontal, PanelRight, + Pin, + PinOff, Plus, Trash2, UserMinus, @@ -77,6 +79,8 @@ import { api } from "@multica/core/api"; import { useModalStore } from "@multica/core/modals"; import { timeAgo } from "@multica/core/utils"; import { cn } from "@multica/ui/lib/utils"; +import { pinListOptions } from "@multica/core/pins"; +import { useCreatePin, useDeletePin } from "@multica/core/pins"; import { ProgressRing } from "./progress-ring"; @@ -245,6 +249,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo // Token usage const { data: usage } = useQuery(issueUsageOptions(id)); + // Pinned state + const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId)); + const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id); + const createPin = useCreatePin(); + const deletePin = useDeletePin(); + // Sub-issue queries const parentIssueId = issue?.parent_issue_id; const { data: parentIssue = null } = useQuery({ @@ -462,6 +472,27 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo )} + + { + if (isPinned) { + deletePin.mutate({ itemType: "issue", itemId: issue.id }); + } else { + createPin.mutate({ item_type: "issue", item_id: issue.id }); + } + }} + > + {isPinned ? : } + + } + /> + {isPinned ? "Unpin from sidebar" : "Pin to sidebar"} + + {/* Pin / Unpin */} + { + if (isPinned) { + deletePin.mutate({ itemType: "issue", itemId: issue.id }); + } else { + createPin.mutate({ item_type: "issue", item_id: issue.id }); + } + }}> + {isPinned ? : } + {isPinned ? "Unpin from sidebar" : "Pin to sidebar"} + + {/* Copy link */} { const url = router.getShareableUrl diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 19ade824d..2b4ee291e 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -1,8 +1,18 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { cn } from "@multica/ui/lib/utils"; import { AppLink, useNavigation } from "../navigation"; +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Inbox, ListTodo, @@ -18,6 +28,7 @@ import { CircleUser, FolderKanban, Ellipsis, + PinOff, } from "lucide-react"; import { WorkspaceAvatar } from "../workspace/workspace-avatar"; import { ActorAvatar } from "@multica/ui/components/common/actor-avatar"; @@ -52,6 +63,9 @@ import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries"; import { api } from "@multica/core/api"; import { useModalStore } from "@multica/core/modals"; import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks"; +import { pinKeys } from "@multica/core/pins/queries"; +import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations"; +import type { PinnedItem } from "@multica/core/types"; const personalNav = [ { href: "/inbox", label: "Inbox", icon: Inbox }, @@ -76,6 +90,60 @@ function DraftDot() { return ; } +function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id }); + const wasDragged = useRef(false); + const { push } = useNavigation(); + + useEffect(() => { + if (isDragging) wasDragged.current = true; + }, [isDragging]); + + const style = { transform: CSS.Transform.toString(transform), transition }; + const href = pin.item_type === "issue" ? `/issues/${pin.item_id}` : `/projects/${pin.item_id}`; + const isActive = pathname === href; + const label = pin.item_type === "issue" && pin.identifier ? `${pin.identifier} ${pin.title}` : pin.title; + + return ( + + { + if (wasDragged.current) { + wasDragged.current = false; + return; + } + push(href); + }} + className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground" + > + {pin.item_type === "issue" ? ( + + ) : ( + + )} + {label} + + + + ); +} + interface AppSidebarProps { /** Rendered above SidebarHeader (e.g. desktop traffic light spacer) */ topSlot?: React.ReactNode; @@ -106,6 +174,26 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } [inboxItems], ); const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId); + const { data: pinnedItems = [] } = useQuery({ + queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"], + queryFn: () => api.listPins(), + enabled: !!wsId, + }); + const deletePin = useDeletePin(); + const reorderPins = useReorderPins(); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = pinnedItems.findIndex((p) => p.id === active.id); + const newIndex = pinnedItems.findIndex((p) => p.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + const reordered = arrayMove(pinnedItems, oldIndex, newIndex); + reorderPins.mutate(reordered); + }, + [pinnedItems, reorderPins], + ); const queryClient = useQueryClient(); const logout = () => { @@ -263,6 +351,28 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } + {pinnedItems.length > 0 && ( + + Pinned + + + p.id)} strategy={verticalListSortingStrategy}> + + {pinnedItems.map((pin: PinnedItem) => ( + deletePin.mutate({ itemType: pin.item_type, itemId: pin.item_id })} + /> + ))} + + + + + + )} + Workspace diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index 2fb7d21b9..af75ed317 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -1,13 +1,15 @@ "use client"; import { useMemo, useState, useCallback, useRef } from "react"; -import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, Trash2, UserMinus } from "lucide-react"; +import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, Pin, PinOff, Trash2, UserMinus } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { cn } from "@multica/ui/lib/utils"; import { toast } from "sonner"; import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types"; import { projectDetailOptions } from "@multica/core/projects/queries"; import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations"; +import { pinListOptions } from "@multica/core/pins"; +import { useCreatePin, useDeletePin } from "@multica/core/pins"; import { issueListOptions } from "@multica/core/issues/queries"; import { useUpdateIssue } from "@multica/core/issues/mutations"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; @@ -184,6 +186,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) { const { getActorName } = useActorName(); const updateProject = useUpdateProject(); const deleteProject = useDeleteProject(); + const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId)); + const isPinned = pinnedItems.some((p) => p.item_type === "project" && p.item_id === projectId); + const createPin = useCreatePin(); + const deletePinMut = useDeletePin(); const descEditorRef = useRef(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [iconPickerOpen, setIconPickerOpen] = useState(false); @@ -256,6 +262,16 @@ export function ProjectDetail({ projectId }: { projectId: string }) { } /> + { + if (isPinned) { + deletePinMut.mutate({ itemType: "project", itemId: projectId }); + } else { + createPin.mutate({ item_type: "project", item_id: projectId }); + } + }}> + {isPinned ? : } + {isPinned ? "Unpin from sidebar" : "Pin to sidebar"} + { navigator.clipboard.writeText(window.location.href); toast.success("Link copied"); @@ -275,6 +291,21 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
+