Skip to content

Commit 1ed3a4e

Browse files
authored
feat(mothership): pin tasks to keep them at the top of the sidebar (#4582)
* feat(mothership): pin tasks to keep them at the top of the sidebar * fix(sidebar): address PR review feedback for pin tasks * fix(posthog): register task_pinned and task_unpinned events * fix(tasks): insert new optimistic tasks below pinned partition
1 parent cdc7513 commit 1ed3a4e

12 files changed

Lines changed: 16043 additions & 30 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: 22 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,8 @@ interface ContextMenuProps {
245247
showOpenInNewTab?: boolean
246248
showMarkAsRead?: boolean
247249
showMarkAsUnread?: boolean
250+
showPin?: boolean
251+
isPinned?: boolean
248252
showRename?: boolean
249253
showCreate?: boolean
250254
showCreateFolder?: boolean
@@ -288,6 +292,7 @@ export function ContextMenu({
288292
onOpenInNewTab,
289293
onMarkAsRead,
290294
onMarkAsUnread,
295+
onTogglePin,
291296
onRename,
292297
onCreate,
293298
onCreateFolder,
@@ -299,6 +304,8 @@ export function ContextMenu({
299304
showOpenInNewTab = false,
300305
showMarkAsRead = false,
301306
showMarkAsUnread = false,
307+
showPin = false,
308+
isPinned = false,
302309
showRename = true,
303310
showCreate = false,
304311
showCreateFolder = false,
@@ -375,7 +382,10 @@ export function ContextMenu({
375382
}, [])
376383

377384
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
378-
const hasStatusSection = (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread)
385+
const hasStatusSection =
386+
(showMarkAsRead && onMarkAsRead) ||
387+
(showMarkAsUnread && onMarkAsUnread) ||
388+
(showPin && onTogglePin)
379389
const hasEditSection =
380390
(showRename && onRename) ||
381391
(showCreate && onCreate) ||
@@ -447,6 +457,17 @@ export function ContextMenu({
447457
Mark as unread
448458
</DropdownMenuItem>
449459
)}
460+
{showPin && onTogglePin && (
461+
<DropdownMenuItem
462+
onSelect={() => {
463+
onTogglePin()
464+
onClose()
465+
}}
466+
>
467+
{isPinned ? <PinOff className='size-[14px]' /> : <Pin className='size-[14px]' />}
468+
{isPinned ? 'Unpin' : 'Pin'}
469+
</DropdownMenuItem>
470+
)}
450471
{hasStatusSection && (hasEditSection || hasCopySection) && <DropdownMenuSeparator />}
451472

452473
{showRename && onRename && (

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

Lines changed: 38 additions & 22 deletions
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()
@@ -790,6 +797,10 @@ export const Sidebar = memo(function Sidebar() {
790797
: [],
791798
[fetchedTasks, workspaceId]
792799
)
800+
const tasksRef = useRef(tasks)
801+
useEffect(() => {
802+
tasksRef.current = tasks
803+
}, [tasks])
793804

794805
const { data: fetchedTables = [] } = useTablesList(workspaceId)
795806
const { data: fetchedFiles = [] } = useWorkspaceFiles(workspaceId)
@@ -930,6 +941,15 @@ export const Sidebar = memo(function Sidebar() {
930941
markTaskUnreadMutation.mutate(ids[0])
931942
}, [])
932943

944+
const handleToggleTaskPin = useCallback(() => {
945+
const { taskIds: ids } = contextMenuSelectionRef.current
946+
if (ids.length !== 1) return
947+
const taskId = ids[0]
948+
const task = tasksRef.current.find((t) => t.id === taskId)
949+
if (!task) return
950+
setTaskPinnedMutation.mutate({ chatId: taskId, pinned: !task.isPinned })
951+
}, [])
952+
933953
const handleStartTaskRename = useCallback(() => {
934954
const { taskIds: ids } = contextMenuSelectionRef.current
935955
if (ids.length !== 1) return
@@ -998,12 +1018,6 @@ export const Sidebar = memo(function Sidebar() {
9981018
})
9991019
}, [workflowId, workflowsLoading])
10001020

1001-
useEffect(() => {
1002-
if (!isOnWorkflowPage && !isCollapsed) {
1003-
setSidebarWidth(SIDEBAR_WIDTH.MIN)
1004-
}
1005-
}, [isOnWorkflowPage, isCollapsed, setSidebarWidth])
1006-
10071021
const handleCreateWorkflow = useCallback(async () => {
10081022
const workflowId = await createWorkflow()
10091023
if (workflowId) {
@@ -1521,6 +1535,7 @@ export const Sidebar = memo(function Sidebar() {
15211535
isSelected={isSelected}
15221536
isActive={!!task.isActive}
15231537
isUnread={!!task.isUnread}
1538+
isPinned={!!task.isPinned}
15241539
isMenuOpen={menuOpenTaskId === task.id}
15251540
showCollapsedTooltips={showCollapsedTooltips}
15261541
onMultiSelectClick={handleTaskClick}
@@ -1771,6 +1786,7 @@ export const Sidebar = memo(function Sidebar() {
17711786
onOpenInNewTab={handleTaskOpenInNewTab}
17721787
onMarkAsRead={handleMarkTaskAsRead}
17731788
onMarkAsUnread={handleMarkTaskAsUnread}
1789+
onTogglePin={handleToggleTaskPin}
17741790
onRename={handleStartTaskRename}
17751791
onDelete={handleDeleteTask}
17761792
showOpenInNewTab={!isMultiTaskContextMenu}
@@ -1780,6 +1796,8 @@ export const Sidebar = memo(function Sidebar() {
17801796
!!activeTaskContextMenuItem &&
17811797
!activeTaskContextMenuItem.isUnread
17821798
}
1799+
showPin={!isMultiTaskContextMenu && !!activeTaskContextMenuItem}
1800+
isPinned={!!activeTaskContextMenuItem?.isPinned}
17831801
showRename={!isMultiTaskContextMenu}
17841802
showDuplicate={false}
17851803
showColorChange={false}
@@ -1800,21 +1818,19 @@ export const Sidebar = memo(function Sidebar() {
18001818
</div>
18011819
</aside>
18021820

1803-
{(isCollapsed || isOnWorkflowPage) && (
1804-
<div
1805-
className={cn(
1806-
'absolute top-0 right-0 bottom-0 z-20 w-[8px] translate-x-1/2',
1807-
isCollapsed ? 'cursor-e-resize' : 'cursor-ew-resize'
1808-
)}
1809-
onMouseDown={isCollapsed ? undefined : handleMouseDown}
1810-
onClick={isCollapsed ? toggleCollapsed : undefined}
1811-
onKeyDown={handleEdgeKeyDown}
1812-
role={isCollapsed ? 'button' : 'separator'}
1813-
tabIndex={0}
1814-
aria-orientation={isCollapsed ? undefined : 'vertical'}
1815-
aria-label={isCollapsed ? 'Expand sidebar' : 'Resize sidebar'}
1816-
/>
1817-
)}
1821+
<div
1822+
className={cn(
1823+
'absolute top-0 right-0 bottom-0 z-20 w-[8px] translate-x-1/2',
1824+
isCollapsed ? 'cursor-e-resize' : 'cursor-ew-resize'
1825+
)}
1826+
onMouseDown={isCollapsed ? undefined : handleMouseDown}
1827+
onClick={isCollapsed ? toggleCollapsed : undefined}
1828+
onKeyDown={handleEdgeKeyDown}
1829+
role={isCollapsed ? 'button' : 'separator'}
1830+
tabIndex={0}
1831+
aria-orientation={isCollapsed ? undefined : 'vertical'}
1832+
aria-label={isCollapsed ? 'Expand sidebar' : 'Resize sidebar'}
1833+
/>
18181834
</div>
18191835

18201836
<SearchModal

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
})

0 commit comments

Comments
 (0)