Skip to content

Commit 5fbce44

Browse files
committed
feat(mothership): pin tasks to keep them at the top of the sidebar
1 parent bdf9ffc commit 5fbce44

11 files changed

Lines changed: 16010 additions & 7 deletions

File tree

apps/sim/app/api/mothership/chats/[chatId]/route.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export const PATCH = withRouteHandler(
142142
const parsed = await parseRequest(updateMothershipChatContract, request, context)
143143
if (!parsed.success) return parsed.response
144144
const { chatId } = parsed.data.params
145-
const { title, isUnread } = parsed.data.body
145+
const { title, isUnread, pinned } = parsed.data.body
146146

147147
const updates: Record<string, unknown> = {}
148148

@@ -157,6 +157,9 @@ export const PATCH = withRouteHandler(
157157
if (isUnread !== undefined) {
158158
updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())`
159159
}
160+
if (pinned !== undefined) {
161+
updates.pinned = pinned
162+
}
160163

161164
const [updatedChat] = await db
162165
.update(copilotChats)
@@ -203,6 +206,16 @@ export const PATCH = withRouteHandler(
203206
}
204207
)
205208
}
209+
if (pinned !== undefined) {
210+
captureServerEvent(
211+
userId,
212+
pinned ? 'task_pinned' : 'task_unpinned',
213+
{ workspace_id: updatedChat.workspaceId },
214+
{
215+
groups: { workspace: updatedChat.workspaceId },
216+
}
217+
)
218+
}
206219
}
207220

208221
return NextResponse.json({ success: true })

apps/sim/app/api/mothership/chats/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4545
updatedAt: copilotChats.updatedAt,
4646
activeStreamId: copilotChats.conversationId,
4747
lastSeenAt: copilotChats.lastSeenAt,
48+
pinned: copilotChats.pinned,
4849
})
4950
.from(copilotChats)
5051
.where(
@@ -54,7 +55,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5455
eq(copilotChats.type, 'mothership')
5556
)
5657
)
57-
.orderBy(desc(copilotChats.updatedAt))
58+
.orderBy(desc(copilotChats.pinned), desc(copilotChats.updatedAt))
5859

5960
const streamMarkers = await reconcileChatStreamMarkers(
6061
chats.map((c) => ({ chatId: c.id, streamId: c.activeStreamId })),

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4+
import { Pin, PinOff } from 'lucide-react'
45
import {
56
Button,
67
DropdownMenu,
@@ -234,6 +235,7 @@ interface ContextMenuProps {
234235
onOpenInNewTab?: () => void
235236
onMarkAsRead?: () => void
236237
onMarkAsUnread?: () => void
238+
onTogglePin?: () => void
237239
onRename?: () => void
238240
onCreate?: () => void
239241
onCreateFolder?: () => void
@@ -245,6 +247,9 @@ interface ContextMenuProps {
245247
showOpenInNewTab?: boolean
246248
showMarkAsRead?: boolean
247249
showMarkAsUnread?: boolean
250+
showPin?: boolean
251+
isPinned?: boolean
252+
disablePin?: boolean
248253
showRename?: boolean
249254
showCreate?: boolean
250255
showCreateFolder?: boolean
@@ -288,6 +293,7 @@ export function ContextMenu({
288293
onOpenInNewTab,
289294
onMarkAsRead,
290295
onMarkAsUnread,
296+
onTogglePin,
291297
onRename,
292298
onCreate,
293299
onCreateFolder,
@@ -299,6 +305,9 @@ export function ContextMenu({
299305
showOpenInNewTab = false,
300306
showMarkAsRead = false,
301307
showMarkAsUnread = false,
308+
showPin = false,
309+
isPinned = false,
310+
disablePin = false,
302311
showRename = true,
303312
showCreate = false,
304313
showCreateFolder = false,
@@ -375,7 +384,10 @@ export function ContextMenu({
375384
}, [])
376385

377386
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
378-
const hasStatusSection = (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread)
387+
const hasStatusSection =
388+
(showMarkAsRead && onMarkAsRead) ||
389+
(showMarkAsUnread && onMarkAsUnread) ||
390+
(showPin && onTogglePin)
379391
const hasEditSection =
380392
(showRename && onRename) ||
381393
(showCreate && onCreate) ||
@@ -447,6 +459,18 @@ export function ContextMenu({
447459
Mark as unread
448460
</DropdownMenuItem>
449461
)}
462+
{showPin && onTogglePin && (
463+
<DropdownMenuItem
464+
disabled={disablePin}
465+
onSelect={() => {
466+
onTogglePin()
467+
onClose()
468+
}}
469+
>
470+
{isPinned ? <PinOff className='size-[14px]' /> : <Pin className='size-[14px]' />}
471+
{isPinned ? 'Unpin' : 'Pin'}
472+
</DropdownMenuItem>
473+
)}
450474
{hasStatusSection && (hasEditSection || hasCopySection) && <DropdownMenuSeparator />}
451475

452476
{showRename && onRename && (

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { Compass, MoreHorizontal } from 'lucide-react'
5+
import { Compass, MoreHorizontal, Pin } from 'lucide-react'
66
import Image from 'next/image'
77
import Link from 'next/link'
88
import { useParams, usePathname, useRouter } from 'next/navigation'
@@ -91,6 +91,7 @@ import {
9191
useMarkTaskRead,
9292
useMarkTaskUnread,
9393
useRenameTask,
94+
useSetTaskPinned,
9495
useTasks,
9596
} from '@/hooks/queries/tasks'
9697
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
@@ -144,6 +145,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
144145
isSelected,
145146
isActive,
146147
isUnread,
148+
isPinned,
147149
isMenuOpen,
148150
showCollapsedTooltips,
149151
onMultiSelectClick,
@@ -156,6 +158,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
156158
isSelected: boolean
157159
isActive: boolean
158160
isUnread: boolean
161+
isPinned: boolean
159162
isMenuOpen: boolean
160163
showCollapsedTooltips: boolean
161164
onMultiSelectClick: (taskId: string, shiftKey: boolean) => void
@@ -219,6 +222,9 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
219222
{!isActive && isUnread && !isCurrentRoute && !isMenuOpen && (
220223
<span className='absolute size-[7px] rounded-full bg-[var(--brand-accent)] group-hover:hidden' />
221224
)}
225+
{!isActive && !isUnread && isPinned && !isCurrentRoute && !isMenuOpen && (
226+
<Pin className='absolute size-[12px] text-[var(--text-icon)] group-hover:hidden' />
227+
)}
222228
<button
223229
type='button'
224230
aria-label='Task options'
@@ -581,6 +587,7 @@ export const Sidebar = memo(function Sidebar() {
581587
const deleteTasksMutation = useDeleteTasks(workspaceId)
582588
const markTaskReadMutation = useMarkTaskRead(workspaceId)
583589
const markTaskUnreadMutation = useMarkTaskUnread(workspaceId)
590+
const setTaskPinnedMutation = useSetTaskPinned(workspaceId)
584591
const renameTaskMutation = useRenameTask(workspaceId)
585592
const tasksHover = useHoverMenu()
586593
const workflowsHover = useHoverMenu()
@@ -930,6 +937,15 @@ export const Sidebar = memo(function Sidebar() {
930937
markTaskUnreadMutation.mutate(ids[0])
931938
}, [])
932939

940+
const handleToggleTaskPin = useCallback(() => {
941+
const { taskIds: ids } = contextMenuSelectionRef.current
942+
if (ids.length !== 1) return
943+
const taskId = ids[0]
944+
const task = tasks.find((t) => t.id === taskId)
945+
if (!task) return
946+
setTaskPinnedMutation.mutate({ chatId: taskId, pinned: !task.isPinned })
947+
}, [tasks])
948+
933949
const handleStartTaskRename = useCallback(() => {
934950
const { taskIds: ids } = contextMenuSelectionRef.current
935951
if (ids.length !== 1) return
@@ -1521,6 +1537,7 @@ export const Sidebar = memo(function Sidebar() {
15211537
isSelected={isSelected}
15221538
isActive={!!task.isActive}
15231539
isUnread={!!task.isUnread}
1540+
isPinned={!!task.isPinned}
15241541
isMenuOpen={menuOpenTaskId === task.id}
15251542
showCollapsedTooltips={showCollapsedTooltips}
15261543
onMultiSelectClick={handleTaskClick}
@@ -1771,6 +1788,7 @@ export const Sidebar = memo(function Sidebar() {
17711788
onOpenInNewTab={handleTaskOpenInNewTab}
17721789
onMarkAsRead={handleMarkTaskAsRead}
17731790
onMarkAsUnread={handleMarkTaskAsUnread}
1791+
onTogglePin={handleToggleTaskPin}
17741792
onRename={handleStartTaskRename}
17751793
onDelete={handleDeleteTask}
17761794
showOpenInNewTab={!isMultiTaskContextMenu}
@@ -1780,6 +1798,8 @@ export const Sidebar = memo(function Sidebar() {
17801798
!!activeTaskContextMenuItem &&
17811799
!activeTaskContextMenuItem.isUnread
17821800
}
1801+
showPin={!isMultiTaskContextMenu && !!activeTaskContextMenuItem}
1802+
isPinned={!!activeTaskContextMenuItem?.isPinned}
17831803
showRename={!isMultiTaskContextMenu}
17841804
showDuplicate={false}
17851805
showColorChange={false}

apps/sim/hooks/queries/tasks.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ describe('tasks query boundary parsing', () => {
5454
updatedAt: '2026-04-11T10:00:00.000Z',
5555
activeStreamId: 'stream-1',
5656
lastSeenAt: null,
57+
pinned: false,
5758
},
5859
],
5960
})
@@ -68,6 +69,7 @@ describe('tasks query boundary parsing', () => {
6869
name: 'Launch plan',
6970
isActive: true,
7071
isUnread: false,
72+
isPinned: false,
7173
})
7274
)
7375
expect(tasks[0]?.updatedAt.toISOString()).toBe('2026-04-11T10:00:00.000Z')
@@ -84,6 +86,7 @@ describe('tasks query boundary parsing', () => {
8486
updatedAt: '2026-04-11T10:00:00.000Z',
8587
activeStreamId: null,
8688
lastSeenAt: null,
89+
pinned: false,
8790
},
8891
],
8992
})

apps/sim/hooks/queries/tasks.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface TaskMetadata {
3434
updatedAt: Date
3535
isActive: boolean
3636
isUnread: boolean
37+
isPinned: boolean
3738
}
3839

3940
export interface TaskChatHistory {
@@ -193,6 +194,7 @@ function mapTask(chat: MothershipTask): TaskMetadata {
193194
isUnread:
194195
chat.activeStreamId === null &&
195196
(chat.lastSeenAt === null || updatedAt > new Date(chat.lastSeenAt)),
197+
isPinned: chat.pinned,
196198
}
197199
}
198200

@@ -538,6 +540,57 @@ export function useMarkTaskUnread(workspaceId?: string) {
538540
})
539541
}
540542

543+
async function setTaskPinned({
544+
chatId,
545+
pinned,
546+
}: {
547+
chatId: string
548+
pinned: boolean
549+
}): Promise<void> {
550+
await requestJson(updateMothershipChatContract, {
551+
params: { chatId },
552+
body: { pinned },
553+
})
554+
}
555+
556+
/**
557+
* Pins or unpins a task with optimistic update. Pinned tasks are sorted to
558+
* the top of the list by the server; the optimistic reducer preserves that
559+
* ordering by partitioning pinned and unpinned tasks while keeping each
560+
* partition in its existing order (server returns desc(updatedAt) within).
561+
*/
562+
export function useSetTaskPinned(workspaceId?: string) {
563+
const queryClient = useQueryClient()
564+
return useMutation({
565+
mutationFn: setTaskPinned,
566+
onMutate: async ({ chatId, pinned }) => {
567+
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
568+
const previousTasks = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
569+
if (!previousTasks) return { previousTasks: undefined }
570+
571+
const updated = previousTasks.map((task) =>
572+
task.id === chatId ? { ...task, isPinned: pinned } : task
573+
)
574+
const pinnedTasks = updated.filter((task) => task.isPinned)
575+
const unpinnedTasks = updated.filter((task) => !task.isPinned)
576+
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), [
577+
...pinnedTasks,
578+
...unpinnedTasks,
579+
])
580+
581+
return { previousTasks }
582+
},
583+
onError: (_err, _variables, context) => {
584+
if (context?.previousTasks) {
585+
queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks)
586+
}
587+
},
588+
onSettled: () => {
589+
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
590+
},
591+
})
592+
}
593+
541594
async function createChat(workspaceId: string): Promise<{ id: string }> {
542595
const { id } = await requestJson(createMothershipChatContract, { body: { workspaceId } })
543596
return { id }
@@ -559,6 +612,7 @@ export function useCreateTask(workspaceId?: string) {
559612
updatedAt: new Date(),
560613
isActive: false,
561614
isUnread: false,
615+
isPinned: false,
562616
}
563617
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), [newTask, ...existing])
564618
},
@@ -597,6 +651,7 @@ export function useForkTask(workspaceId?: string) {
597651
updatedAt: new Date(),
598652
isActive: false,
599653
isUnread: false,
654+
isPinned: false,
600655
}
601656
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), [
602657
optimisticTask,

apps/sim/lib/api/contracts/mothership-tasks.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@ export const updateMothershipChatBodySchema = z
1717
.object({
1818
title: z.string().trim().min(1).max(200).optional(),
1919
isUnread: z.boolean().optional(),
20+
pinned: z.boolean().optional(),
2021
})
21-
.refine((data) => data.title !== undefined || data.isUnread !== undefined, {
22-
message: 'At least one field must be provided',
23-
})
22+
.refine(
23+
(data) => data.title !== undefined || data.isUnread !== undefined || data.pinned !== undefined,
24+
{
25+
message: 'At least one field must be provided',
26+
}
27+
)
2428

2529
export const createMothershipChatBodySchema = z.object({
2630
workspaceId: z.string().min(1),
@@ -188,6 +192,7 @@ export const mothershipTaskSchema = z.object({
188192
updatedAt: dateStringSchema,
189193
activeStreamId: z.string().nullable(),
190194
lastSeenAt: dateStringSchema.nullable(),
195+
pinned: z.boolean(),
191196
})
192197

193198
export const listMothershipChatsContract = defineRouteContract({
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "copilot_chats" ADD COLUMN "pinned" boolean DEFAULT false NOT NULL;

0 commit comments

Comments
 (0)