import { useState } from "react"; import type { Conversation, SavedResponse } from "../types"; import { PROFESSIONS, SUPPORTED_LANGUAGES } from "../types"; import { exportAllSavedResponses } from "../lib/export"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Truncate saved-response question text to ~80 chars (within ±5 tolerance). */ const QUESTION_TRUNCATE_LENGTH = 80; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Truncates a question string to approximately 80 characters for sidebar * display. When the original exceeds 80 characters the result is truncated * at the nearest word boundary within [75, 85] chars and an ellipsis appended. * * Property 5: displayed length is within [75, 85] when original > 80 chars. * Requirements: 8.3 */ export function truncateQuestion(text: string): string { if (text.length <= QUESTION_TRUNCATE_LENGTH) return text; // Slice at exactly 80 chars then walk back to the last space within ±5. let cutPoint = QUESTION_TRUNCATE_LENGTH; const spaceIndex = text.lastIndexOf(" ", QUESTION_TRUNCATE_LENGTH); if (spaceIndex >= 75) { cutPoint = spaceIndex; } return text.slice(0, cutPoint).trimEnd() + "…"; } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- interface CollapsibleSectionProps { title: string; defaultOpen?: boolean; children: React.ReactNode; } function CollapsibleSection({ title, defaultOpen = true, children, }: CollapsibleSectionProps) { const [isOpen, setIsOpen] = useState(defaultOpen); return (
{isOpen && (
{children}
)}
); } // --------------------------------------------------------------------------- // Sidebar props // --------------------------------------------------------------------------- export interface SidebarProps { /** Conversations ordered by last_active_at desc (caller is responsible for ordering). */ conversations: Conversation[]; /** Saved responses ordered by saved_at desc (caller is responsible for ordering). */ savedResponses: SavedResponse[]; /** Clears the active ChatState and mints a new UUID. */ onNewConversation: () => void; /** Restores a past conversation into the active ChatState. */ onSelectConversation: (id: string) => void; /** Removes a conversation from localStorage. */ onDeleteConversation: (id: string) => void; /** Forks a saved response into a new ChatState. */ onSelectSavedResponse: (id: string) => void; /** Removes a saved response from localStorage. */ onDeleteSavedResponse: (id: string) => void; /** Currently selected profession (empty string = none). */ profession: string; onProfessionChange: (profession: string) => void; /** Currently selected language code. */ language: string; onLanguageChange: (language: string) => void; /** * True when the current Chat_Session has at least one completed Q&A pair. * Used to decide whether to show the profession-change notice. */ hasMessages: boolean; /** * Called when the user clicks the one-click re-run option in the * profession-change notice. */ onRerunWithProfession: () => void; } // --------------------------------------------------------------------------- // Sidebar component // --------------------------------------------------------------------------- /** * Dark sidebar containing: * - "New conversation" button * - "Download saved responses" button * - Profession selector * - Language selector * - Profession-change notice (when profession changes after ≥1 Q&A pair) * - Collapsible "Past conversations" section * - Collapsible "Saved responses" section * * Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 9.1, 9.2, 9.3, 9.4, 9.5 */ export function Sidebar({ conversations, savedResponses, onNewConversation, onSelectConversation, onDeleteConversation, onSelectSavedResponse, onDeleteSavedResponse, profession, onProfessionChange, language, onLanguageChange, hasMessages, onRerunWithProfession, }: SidebarProps) { /** * Track the profession that was active when the last Q&A pair completed. * When `profession` diverges from this value and `hasMessages` is true, * we show the profession-change notice. */ const [committedProfession, setCommittedProfession] = useState(profession); const [showProfessionNotice, setShowProfessionNotice] = useState(false); function handleProfessionChange(newProfession: string) { onProfessionChange(newProfession); if (hasMessages && newProfession !== committedProfession) { setShowProfessionNotice(true); } else { setShowProfessionNotice(false); } } function handleRerun() { setCommittedProfession(profession); setShowProfessionNotice(false); onRerunWithProfession(); } function handleDismissNotice() { setCommittedProfession(profession); setShowProfessionNotice(false); } function handleNewConversation() { setCommittedProfession(profession); setShowProfessionNotice(false); onNewConversation(); } return ( ); } // --------------------------------------------------------------------------- // ConversationRow // --------------------------------------------------------------------------- interface ConversationRowProps { conversation: Conversation; onSelect: (id: string) => void; onDelete: (id: string) => void; } function ConversationRow({ conversation, onSelect, onDelete, }: ConversationRowProps) { const fullQuestion = conversation.messages.find((m) => m.role === "user")?.content || conversation.title; return (
); } // --------------------------------------------------------------------------- // SavedResponseRow // --------------------------------------------------------------------------- interface SavedResponseRowProps { savedResponse: SavedResponse; onSelect: (id: string) => void; onDelete: (id: string) => void; } function SavedResponseRow({ savedResponse, onSelect, onDelete, }: SavedResponseRowProps) { return (
); }