From 48faec8f8527d5754ca6ef431c34ad472c17ae07 Mon Sep 17 00:00:00 2001 From: Naresh Silla Date: Sun, 14 Dec 2025 12:02:41 +0530 Subject: [PATCH 1/3] Add pin/unpin functionality for conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements a conversation pinning feature that allows users to pin important chats to the top of their chat list for quick access. Changes: - Added useTogglePinChat mutation in ChatAPI.ts to toggle pin status - Updated fetchChats query to sort pinned chats first (ORDER BY pinned DESC) - Removed deprecated comment from pinned field in Chat type - Added Pin/PinOff icons from lucide-react to AppSidebar - Implemented pin toggle button in chat list items - Pin icon shows as filled when chat is pinned, appears on hover when unpinned - Added tooltip showing "Pin chat" or "Unpin chat" based on state The pinned field already existed in the database schema but was marked as deprecated and unused. This change activates that functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/core/chorus/api/ChatAPI.ts | 27 +++++++++++++++++++++--- src/ui/components/AppSidebar.tsx | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/core/chorus/api/ChatAPI.ts b/src/core/chorus/api/ChatAPI.ts index e9122dfe..0be9c152 100644 --- a/src/core/chorus/api/ChatAPI.ts +++ b/src/core/chorus/api/ChatAPI.ts @@ -36,8 +36,7 @@ export type Chat = { projectContextSummaryIsStale: boolean; replyToId: string | null; gcPrototype: boolean; - - pinned: boolean; // deprecated + pinned: boolean; // Cost tracking totalCostUsd?: number; @@ -103,7 +102,7 @@ export async function fetchChats(): Promise { project_context_summary, project_context_summary_is_stale, reply_to_id, gc_prototype_chat, total_cost_usd FROM chats WHERE reply_to_id IS NULL - ORDER BY updated_at DESC`, + ORDER BY pinned DESC, updated_at DESC`, ) .then((rows) => rows.map(readChat)); } @@ -396,3 +395,25 @@ export function useRenameChat() { }, }); } + +export function useTogglePinChat() { + const queryClient = useQueryClient(); + const cacheUpdateChat = useCacheUpdateChat(); + + return useMutation({ + mutationKey: ["togglePinChat"] as const, + mutationFn: async ({ chatId, pinned }: { chatId: string; pinned: boolean }) => { + await db.execute("UPDATE chats SET pinned = $1 WHERE id = $2", [ + pinned ? 1 : 0, + chatId, + ]); + return { chatId, pinned }; + }, + onSuccess: async (_data, variables) => { + cacheUpdateChat(variables.chatId, (chat) => { + chat.pinned = variables.pinned; + }); + await queryClient.invalidateQueries(chatQueries.list()); + }, + }); +} diff --git a/src/ui/components/AppSidebar.tsx b/src/ui/components/AppSidebar.tsx index e48bf694..a167ff74 100644 --- a/src/ui/components/AppSidebar.tsx +++ b/src/ui/components/AppSidebar.tsx @@ -9,6 +9,8 @@ import { SquarePlusIcon, ArrowBigUpIcon, EllipsisIcon, + Pin, + PinOff, } from "lucide-react"; import { Sidebar, @@ -821,6 +823,7 @@ function ChatListItem({ chat, isActive }: { chat: Chat; isActive: boolean }) { mutateAsync: deleteChatMutateAsync, isPending: deleteChatIsPending, } = ChatAPI.useDeleteChat(); + const { mutate: togglePinChat } = ChatAPI.useTogglePinChat(); const { data: parentChat } = useQuery( ChatAPI.chatQueries.detail(chat.parentChatId ?? undefined), ); @@ -883,11 +886,24 @@ function ChatListItem({ chat, isActive }: { chat: Chat; isActive: boolean }) { ); const showCost = settings?.showCost ?? false; + const handleTogglePin = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + togglePinChat({ + chatId: chat.id, + pinned: !chat.pinned, + }); + }, + [chat.id, chat.pinned, togglePinChat], + ); + return ( void; onStopEdit: () => void; onSubmitEdit: (newTitle: string) => Promise; + onTogglePin: (e: React.MouseEvent) => void; onDelete: () => void; onConfirmDelete: () => void; deleteIsPending: boolean; @@ -931,6 +950,7 @@ const ChatListItemView = React.memo( chatId, chatTitle, isNewChat, + isPinned, parentChatId, parentChatTitle, isActive, @@ -938,6 +958,7 @@ const ChatListItemView = React.memo( onStartEdit, onStopEdit, onSubmitEdit, + onTogglePin, onDelete, onConfirmDelete, deleteIsPending, @@ -1021,6 +1042,20 @@ const ChatListItemView = React.memo( {/* chat actions */}
+ + +
+ {isPinned ? ( + + ) : ( + + )} +
+
+ + {isPinned ? "Unpin chat" : "Pin chat"} + +
Date: Sun, 14 Dec 2025 12:24:00 +0530 Subject: [PATCH 2/3] Fix cache update sorting to respect pinned status Addresses code review comment from greptile-apps bot. The useCacheUpdateChat function was sorting only by updatedAt, which didn't match the database query sorting (pinned DESC, updated_at DESC). Now sorts pinned chats first, then by updatedAt. Co-Authored-By: Claude Sonnet 4.5 --- src/core/chorus/api/ChatAPI.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/chorus/api/ChatAPI.ts b/src/core/chorus/api/ChatAPI.ts index 0be9c152..284ad4e6 100644 --- a/src/core/chorus/api/ChatAPI.ts +++ b/src/core/chorus/api/ChatAPI.ts @@ -140,9 +140,13 @@ export function useCacheUpdateChat() { updateFn(chat); // NOTE: We don't always need to sort, if this becomes expensive we could gate // this behind a flag - draft.sort((a, b) => - b.updatedAt.localeCompare(a.updatedAt), - ); + draft.sort((a, b) => { + // Sort pinned chats first, then by updatedAt + if (a.pinned !== b.pinned) { + return b.pinned ? 1 : -1; + } + return b.updatedAt.localeCompare(a.updatedAt); + }); } }), ); From 7b42ed18c04ce4dcd38055effbb501314eee1531 Mon Sep 17 00:00:00 2001 From: Naresh Silla Date: Sun, 14 Dec 2025 14:51:05 +0530 Subject: [PATCH 3/3] Add chat export functionality (JSON and Markdown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements export feature that allows users to export chats as JSON or Markdown files. Properly handles multi-model conversations by grouping AI responses side-by-side for each user message turn. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/core/chorus/api/ExportAPI.ts | 163 +++++++++++++++++++++++++++++++ src/ui/components/AppSidebar.tsx | 64 ++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/core/chorus/api/ExportAPI.ts diff --git a/src/core/chorus/api/ExportAPI.ts b/src/core/chorus/api/ExportAPI.ts new file mode 100644 index 00000000..5e795525 --- /dev/null +++ b/src/core/chorus/api/ExportAPI.ts @@ -0,0 +1,163 @@ +import { db } from "../DB"; +import { fetchChat } from "./ChatAPI"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeTextFile } from "@tauri-apps/plugin-fs"; + +interface MessageRow { + message_id: string; + message_set_id: string; + model: string; + text: string; + created_at: string; +} + +interface Turn { + messageSetId: string; + user: { + content: string; + timestamp: string; + }; + responses: Array<{ + model: string; + content: string; + timestamp: string; + }>; +} + +interface ExportData { + chatId: string; + title: string; + createdAt: string; + turns: Turn[]; +} + +async function fetchChatMessages(chatId: string): Promise { + const messages = await db.select( + `SELECT + m.id as message_id, + m.message_set_id, + m.model, + CASE + WHEN m.model = 'user' THEN COALESCE(m.text, '') + ELSE COALESCE(NULLIF(m.text, ''), mp.content, '') + END as text, + m.created_at + FROM messages m + LEFT JOIN message_parts mp ON m.id = mp.message_id AND m.chat_id = mp.chat_id + WHERE m.chat_id = ? + ORDER BY m.created_at ASC`, + [chatId], + ); + return messages; +} + +function groupMessagesByTurns(messages: MessageRow[]): Turn[] { + const turnMap = new Map(); + + for (const message of messages) { + if (!turnMap.has(message.message_set_id)) { + turnMap.set(message.message_set_id, { + messageSetId: message.message_set_id, + user: { + content: "", + timestamp: "", + }, + responses: [], + }); + } + + const turn = turnMap.get(message.message_set_id)!; + + if (message.model === "user") { + turn.user = { + content: message.text, + timestamp: message.created_at, + }; + } else { + turn.responses.push({ + model: message.model, + content: message.text, + timestamp: message.created_at, + }); + } + } + + return Array.from(turnMap.values()); +} + +async function fetchExportData(chatId: string): Promise { + const chat = await fetchChat(chatId); + const messages = await fetchChatMessages(chatId); + const turns = groupMessagesByTurns(messages); + + return { + chatId: chat.id, + title: chat.title, + createdAt: chat.createdAt, + turns, + }; +} + +function formatAsJSON(data: ExportData): string { + return JSON.stringify(data, null, 2); +} + +function formatAsMarkdown(data: ExportData): string { + let md = `# ${data.title}\n`; + md += `Created: ${new Date(data.createdAt).toLocaleDateString()}\n\n`; + md += `---\n\n`; + + for (const turn of data.turns) { + // User message + if (turn.user.content) { + md += `### You\n${turn.user.content}\n\n`; + } + + // AI responses + for (const response of turn.responses) { + md += `### ${response.model}\n${response.content}\n\n`; + } + + md += `---\n\n`; + } + + return md; +} + +export async function exportChatAsJSON(chatId: string): Promise { + const data = await fetchExportData(chatId); + const jsonContent = formatAsJSON(data); + + const filePath = await save({ + defaultPath: `${data.title || "chat"}.json`, + filters: [ + { + name: "JSON", + extensions: ["json"], + }, + ], + }); + + if (filePath) { + await writeTextFile(filePath, jsonContent); + } +} + +export async function exportChatAsMarkdown(chatId: string): Promise { + const data = await fetchExportData(chatId); + const mdContent = formatAsMarkdown(data); + + const filePath = await save({ + defaultPath: `${data.title || "chat"}.md`, + filters: [ + { + name: "Markdown", + extensions: ["md"], + }, + ], + }); + + if (filePath) { + await writeTextFile(filePath, mdContent); + } +} diff --git a/src/ui/components/AppSidebar.tsx b/src/ui/components/AppSidebar.tsx index a167ff74..a3a40d11 100644 --- a/src/ui/components/AppSidebar.tsx +++ b/src/ui/components/AppSidebar.tsx @@ -11,6 +11,7 @@ import { EllipsisIcon, Pin, PinOff, + Download, } from "lucide-react"; import { Sidebar, @@ -56,9 +57,16 @@ import { DialogHeader, DialogTitle, } from "./ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; import * as ChatAPI from "@core/chorus/api/ChatAPI"; import * as ProjectAPI from "@core/chorus/api/ProjectAPI"; import { formatCost } from "@core/chorus/api/CostAPI"; +import * as ExportAPI from "@core/chorus/api/ExportAPI"; import RetroSpinner from "./ui/retro-spinner"; import FeedbackButton from "./FeedbackButton"; import { SpeakerLoudIcon } from "@radix-ui/react-icons"; @@ -898,6 +906,36 @@ function ChatListItem({ chat, isActive }: { chat: Chat; isActive: boolean }) { [chat.id, chat.pinned, togglePinChat], ); + const handleExportJSON = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + await ExportAPI.exportChatAsJSON(chat.id); + toast.success("Chat exported as JSON"); + } catch (error) { + toast.error("Failed to export chat"); + console.error(error); + } + }, + [chat.id], + ); + + const handleExportMarkdown = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + await ExportAPI.exportChatAsMarkdown(chat.id); + toast.success("Chat exported as Markdown"); + } catch (error) { + toast.error("Failed to export chat"); + console.error(error); + } + }, + [chat.id], + ); + return ( void; onSubmitEdit: (newTitle: string) => Promise; onTogglePin: (e: React.MouseEvent) => void; + onExportJSON: (e: React.MouseEvent) => void; + onExportMarkdown: (e: React.MouseEvent) => void; onDelete: () => void; onConfirmDelete: () => void; deleteIsPending: boolean; @@ -959,6 +1001,8 @@ const ChatListItemView = React.memo( onStopEdit, onSubmitEdit, onTogglePin, + onExportJSON, + onExportMarkdown, onDelete, onConfirmDelete, deleteIsPending, @@ -1056,6 +1100,26 @@ const ChatListItemView = React.memo( {isPinned ? "Unpin chat" : "Pin chat"} + + +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+
+ e.stopPropagation()}> + + Export as JSON + + + Export as Markdown + + +