diff --git a/src/core/chorus/api/ChatAPI.ts b/src/core/chorus/api/ChatAPI.ts index e9122dfe..284ad4e6 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)); } @@ -141,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); + }); } }), ); @@ -396,3 +399,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/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 e48bf694..a3a40d11 100644 --- a/src/ui/components/AppSidebar.tsx +++ b/src/ui/components/AppSidebar.tsx @@ -9,6 +9,9 @@ import { SquarePlusIcon, ArrowBigUpIcon, EllipsisIcon, + Pin, + PinOff, + Download, } from "lucide-react"; import { Sidebar, @@ -54,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"; @@ -821,6 +831,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 +894,54 @@ 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], + ); + + 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; onStopEdit: () => 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; @@ -931,6 +992,7 @@ const ChatListItemView = React.memo( chatId, chatTitle, isNewChat, + isPinned, parentChatId, parentChatTitle, isActive, @@ -938,6 +1000,9 @@ const ChatListItemView = React.memo( onStartEdit, onStopEdit, onSubmitEdit, + onTogglePin, + onExportJSON, + onExportMarkdown, onDelete, onConfirmDelete, deleteIsPending, @@ -1021,6 +1086,40 @@ const ChatListItemView = React.memo( {/* chat actions */}
+ + +
+ {isPinned ? ( + + ) : ( + + )} +
+
+ + {isPinned ? "Unpin chat" : "Pin chat"} + +
+ + +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+
+ e.stopPropagation()}> + + Export as JSON + + + Export as Markdown + + +