Skip to content

Commit 8905fe6

Browse files
authored
Feat: Pin & Unpin chats across app (sidebar + history) (#261)
* feat: pin & unpin chats in sidebar layout * feat: pin & unpin chats from command-history * feat: pin & unpin chats from drawer-history * update: add pinned & pinned_at columns to db schema * refactor: replace Response with NextResponse and fix CodeQL warning
1 parent bee0d83 commit 8905fe6

File tree

12 files changed

+275
-28
lines changed

12 files changed

+275
-28
lines changed

INSTALL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ CREATE TABLE chats (
196196
created_at TIMESTAMPTZ DEFAULT NOW(),
197197
updated_at TIMESTAMPTZ DEFAULT NOW(),
198198
public BOOLEAN DEFAULT FALSE NOT NULL,
199+
pinned BOOLEAN DEFAULT FALSE NOT NULL,
200+
pinned_at TIMESTAMPTZ NULL,
199201
CONSTRAINT chats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
200202
CONSTRAINT chats_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
201203
);

app/api/toggle-chat-pin/route.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { createClient } from "@/lib/supabase/server"
2+
import { NextResponse } from "next/server"
3+
4+
export async function POST(request: Request) {
5+
try {
6+
const supabase = await createClient()
7+
const { chatId, pinned } = await request.json()
8+
9+
if (!chatId || typeof pinned !== "boolean") {
10+
return NextResponse.json(
11+
{ error: "Missing chatId or pinned" },
12+
{ status: 400 }
13+
)
14+
}
15+
16+
if (!supabase) {
17+
return NextResponse.json({ success: true }, { status: 200 })
18+
}
19+
20+
const toggle = pinned
21+
? { pinned: true, pinned_at: new Date().toISOString() }
22+
: { pinned: false, pinned_at: null }
23+
24+
const { error } = await supabase
25+
.from("chats")
26+
.update(toggle)
27+
.eq("id", chatId)
28+
29+
if (error) {
30+
return NextResponse.json(
31+
{ error: "Failed to update pinned" },
32+
{ status: 500 }
33+
)
34+
}
35+
36+
return NextResponse.json({ success: true }, { status: 200 })
37+
} catch (error) {
38+
console.error("toggle-chat-pin unhandled error:", error)
39+
return NextResponse.json(
40+
{ error: "Internal server error" },
41+
{ status: 500 }
42+
)
43+
}
44+
}

app/components/history/command-history.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@ import {
2424
TooltipContent,
2525
TooltipTrigger,
2626
} from "@/components/ui/tooltip"
27+
import { useChats } from "@/lib/chat-store/chats/provider"
2728
import { useChatSession } from "@/lib/chat-store/session/provider"
2829
import type { Chats } from "@/lib/chat-store/types"
2930
import { useChatPreview } from "@/lib/hooks/use-chat-preview"
3031
import { useUserPreferences } from "@/lib/user-preference-store/provider"
3132
import { cn } from "@/lib/utils"
3233
import { Check, PencilSimple, TrashSimple, X } from "@phosphor-icons/react"
34+
import { Pin, PinOff } from "lucide-react"
3335
import { useRouter } from "next/navigation"
3436
import { useCallback, useMemo, useRef, useState } from "react"
3537
import { ChatPreviewPanel } from "./chat-preview-panel"
@@ -208,6 +210,7 @@ function CommandItemRow({
208210
}: CommandItemRowProps) {
209211
const { chatId } = useChatSession()
210212
const isCurrentChat = chat.id === chatId
213+
const { togglePinned } = useChats()
211214

212215
return (
213216
<>
@@ -220,10 +223,33 @@ function CommandItemRow({
220223

221224
<div className="relative flex min-w-[140px] flex-shrink-0 items-center justify-end">
222225
<div className="text-muted-foreground mr-2 text-xs transition-opacity duration-200 group-hover:opacity-0">
223-
{formatDate(chat.created_at)}
226+
{formatDate(chat.updated_at || chat.created_at)}
224227
</div>
225228

226229
<div className="absolute right-0 flex translate-x-1 gap-1 opacity-0 transition-all duration-200 group-hover:translate-x-0 group-hover:opacity-100">
230+
<Tooltip>
231+
<TooltipTrigger asChild>
232+
<Button
233+
size="icon"
234+
variant="ghost"
235+
className="group/edit text-muted-foreground hover:bg-primary/10 size-8 transition-colors duration-150"
236+
onClick={(e) => {
237+
e.stopPropagation()
238+
togglePinned(chat.id, !chat.pinned)
239+
}}
240+
disabled={!!editingId || !!deletingId}
241+
aria-label={chat.pinned ? "Unpin" : "Pin"}
242+
>
243+
{chat.pinned ? (
244+
<PinOff className="group-hover/edit:text-primary size-3 stroke-[1.5px] transition-colors duration-150" />
245+
) : (
246+
<Pin className="group-hover/edit:text-primary size-3 stroke-[1.5px] transition-colors duration-150" />
247+
)}
248+
</Button>
249+
</TooltipTrigger>
250+
<TooltipContent>{chat.pinned ? "Unpin" : "Pin"}</TooltipContent>
251+
</Tooltip>
252+
227253
<Tooltip>
228254
<TooltipTrigger asChild>
229255
<Button
@@ -440,6 +466,8 @@ export function CommandHistory({
440466
[chatHistory, searchQuery]
441467
)
442468

469+
const { pinnedChats } = useChats()
470+
443471
const activePreviewChatId =
444472
hoveredChatId || (isPreviewPanelHovered ? hoveredChatId : null)
445473

@@ -570,6 +598,18 @@ export function CommandHistory({
570598
<CommandEmpty>No chat history found.</CommandEmpty>
571599
)}
572600

601+
{!searchQuery && pinnedChats.length > 0 && (
602+
<CommandGroup
603+
heading={
604+
<div className="flex items-center gap-1 font-semibold break-all">
605+
<Pin className="size-3" />
606+
Pinned
607+
</div>
608+
}
609+
>
610+
{pinnedChats.map((chat) => renderChatItem(chat))}
611+
</CommandGroup>
612+
)}
573613
{searchQuery ? (
574614
<CommandGroup className="p-1.5">
575615
{filteredChat.map((chat) => renderChatItem(chat))}

app/components/history/drawer-history.tsx

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
TooltipContent,
88
TooltipTrigger,
99
} from "@/components/ui/tooltip"
10+
import { useChats } from "@/lib/chat-store/chats/provider"
1011
import { Chats } from "@/lib/chat-store/types"
1112
import {
1213
Check,
@@ -15,9 +16,10 @@ import {
1516
TrashSimple,
1617
X,
1718
} from "@phosphor-icons/react"
19+
import { Pin, PinOff } from "lucide-react"
1820
import Link from "next/link"
1921
import { useParams } from "next/navigation"
20-
import { useCallback, useMemo, useState } from "react"
22+
import React, { useCallback, useMemo, useState } from "react"
2123
import { formatDate, groupChatsByDate } from "./utils"
2224

2325
type DrawerHistoryProps = {
@@ -37,21 +39,25 @@ export function DrawerHistory({
3739
isOpen,
3840
setIsOpen,
3941
}: DrawerHistoryProps) {
42+
const { pinnedChats, togglePinned } = useChats()
4043
const [searchQuery, setSearchQuery] = useState("")
4144
const [editingId, setEditingId] = useState<string | null>(null)
4245
const [editTitle, setEditTitle] = useState("")
4346
const [deletingId, setDeletingId] = useState<string | null>(null)
4447
const params = useParams<{ chatId: string }>()
4548

46-
const handleOpenChange = useCallback((open: boolean) => {
47-
setIsOpen(open)
48-
if (!open) {
49-
setSearchQuery("")
50-
setEditingId(null)
51-
setEditTitle("")
52-
setDeletingId(null)
53-
}
54-
}, [setIsOpen])
49+
const handleOpenChange = useCallback(
50+
(open: boolean) => {
51+
setIsOpen(open)
52+
if (!open) {
53+
setSearchQuery("")
54+
setEditingId(null)
55+
setEditTitle("")
56+
setDeletingId(null)
57+
}
58+
},
59+
[setIsOpen]
60+
)
5561

5662
const handleEdit = useCallback((chat: Chats) => {
5763
setEditingId(chat.id)
@@ -216,11 +222,28 @@ export function DrawerHistory({
216222
{chat.title || "Untitled Chat"}
217223
</span>
218224
<span className="mr-2 text-xs font-normal text-gray-500">
219-
{formatDate(chat?.created_at)}
225+
{formatDate(chat?.updated_at || chat?.created_at)}
220226
</span>
221227
</Link>
222228
<div className="flex items-center">
223229
<div className="flex gap-1">
230+
<Button
231+
size="icon"
232+
variant="ghost"
233+
className="text-muted-foreground hover:text-foreground size-8"
234+
onClick={(e) => {
235+
e.preventDefault()
236+
togglePinned(chat.id, !chat.pinned)
237+
}}
238+
type="button"
239+
aria-label={chat.pinned ? "Unpin" : "Pin"}
240+
>
241+
{chat.pinned ? (
242+
<PinOff className="size-4 stroke-[1.5px]" />
243+
) : (
244+
<Pin className="size-4 stroke-[1.5px]" />
245+
)}
246+
</Button>
224247
<Button
225248
size="icon"
226249
variant="ghost"
@@ -264,6 +287,7 @@ export function DrawerHistory({
264287
handleCancelDelete,
265288
handleEdit,
266289
handleDelete,
290+
togglePinned,
267291
]
268292
)
269293

@@ -301,17 +325,29 @@ export function DrawerHistory({
301325
{filteredChat.map((chat) => renderChatItem(chat))}
302326
</div>
303327
) : (
304-
// When not searching, display grouped by date
305-
groupedChats?.map((group) => (
306-
<div key={group.name} className="space-y-0.5">
307-
<h3 className="text-muted-foreground pl-2 text-sm font-medium">
308-
{group.name}
309-
</h3>
310-
<div className="space-y-2">
311-
{group.chats.map((chat) => renderChatItem(chat))}
328+
<>
329+
{pinnedChats.length > 0 && (
330+
<div className="space-y-0.5">
331+
<h3 className="text-muted-foreground flex items-center gap-1 pl-2 text-sm font-medium">
332+
<Pin className="size-3" />
333+
Pinned
334+
</h3>
335+
<div className="space-y-2">
336+
{pinnedChats.map((chat) => renderChatItem(chat))}
337+
</div>
338+
</div>
339+
)}
340+
{groupedChats?.map((group) => (
341+
<div key={group.name} className="space-y-0.5">
342+
<h3 className="text-muted-foreground pl-2 text-sm font-medium">
343+
{group.name}
344+
</h3>
345+
<div className="space-y-2">
346+
{group.chats.map((chat) => renderChatItem(chat))}
347+
</div>
312348
</div>
313-
</div>
314-
))
349+
))}
350+
</>
315351
)}
316352
</div>
317353
</ScrollArea>

app/components/history/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function groupChatsByDate(
3030

3131
chats.forEach((chat) => {
3232
if (chat.project_id) return
33+
if (chat.pinned) return
3334

3435
if (!chat.updated_at) {
3536
todayChats.push(chat)

app/components/layout/sidebar/app-sidebar.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
NotePencilIcon,
1919
X,
2020
} from "@phosphor-icons/react"
21+
import { Pin } from "lucide-react"
2122
import { useParams, useRouter } from "next/navigation"
2223
import { useMemo } from "react"
2324
import { HistoryTrigger } from "../../history/history-trigger"
@@ -27,7 +28,7 @@ import { SidebarProject } from "./sidebar-project"
2728
export function AppSidebar() {
2829
const isMobile = useBreakpoint(768)
2930
const { setOpenMobile } = useSidebar()
30-
const { chats, isLoading } = useChats()
31+
const { chats, pinnedChats, isLoading } = useChats()
3132
const params = useParams<{ chatId: string }>()
3233
const currentChatId = params.chatId
3334

@@ -95,6 +96,17 @@ export function AppSidebar() {
9596
<div className="h-full" />
9697
) : hasChats ? (
9798
<div className="space-y-5">
99+
{pinnedChats.length > 0 && (
100+
<div className="space-y-5">
101+
<SidebarList
102+
key="pinned"
103+
title="Pinned"
104+
icon={<Pin className="size-3" />}
105+
items={pinnedChats}
106+
currentChatId={currentChatId}
107+
/>
108+
</div>
109+
)}
98110
{groupedChats?.map((group) => (
99111
<SidebarList
100112
key={group.name}

app/components/layout/sidebar/sidebar-item-menu.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useMessages } from "@/lib/chat-store/messages/provider"
1010
import { useChatSession } from "@/lib/chat-store/session/provider"
1111
import { Chat } from "@/lib/chat-store/types"
1212
import { DotsThree, PencilSimple, Trash } from "@phosphor-icons/react"
13+
import { Pin, PinOff } from "lucide-react"
1314
import { useRouter } from "next/navigation"
1415
import { useState } from "react"
1516
import { DialogDeleteChat } from "./dialog-delete-chat"
@@ -28,7 +29,7 @@ export function SidebarItemMenu({
2829
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
2930
const router = useRouter()
3031
const { deleteMessages } = useMessages()
31-
const { deleteChat } = useChats()
32+
const { deleteChat, togglePinned } = useChats()
3233
const { chatId } = useChatSession()
3334
const isMobile = useBreakpoint(768)
3435

@@ -53,6 +54,21 @@ export function SidebarItemMenu({
5354
</button>
5455
</DropdownMenuTrigger>
5556
<DropdownMenuContent align="end" className="w-40">
57+
<DropdownMenuItem
58+
className="cursor-pointer"
59+
onClick={(e) => {
60+
e.preventDefault()
61+
e.stopPropagation()
62+
togglePinned(chat.id, !chat.pinned)
63+
}}
64+
>
65+
{chat.pinned ? (
66+
<PinOff size={16} className="mr-2" />
67+
) : (
68+
<Pin size={16} className="mr-2" />
69+
)}
70+
{chat.pinned ? "Unpin" : "Pin"}
71+
</DropdownMenuItem>
5672
<DropdownMenuItem
5773
className="cursor-pointer"
5874
onClick={(e) => {

app/components/layout/sidebar/sidebar-list.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
import { Chat } from "@/lib/chat-store/types"
2+
import { ReactNode } from "react"
23
import { SidebarItem } from "./sidebar-item"
34

45
type SidebarListProps = {
56
title: string
7+
icon?: ReactNode
68
items: Chat[]
79
currentChatId: string
810
}
911

10-
export function SidebarList({ title, items, currentChatId }: SidebarListProps) {
12+
export function SidebarList({
13+
title,
14+
icon,
15+
items,
16+
currentChatId,
17+
}: SidebarListProps) {
1118
return (
1219
<div>
13-
<h3 className="overflow-hidden px-2 pt-3 pb-2 text-xs font-semibold break-all text-ellipsis">
20+
<h3 className="flex items-center gap-1 overflow-hidden px-2 pt-3 pb-2 text-xs font-semibold break-all text-ellipsis">
21+
{icon && <span>{icon}</span>}
1422
{title}
1523
</h3>
1624
<div className="space-y-0.5">

0 commit comments

Comments
 (0)