Spaces:
Runtime error
Runtime error
| 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 ( | |
| <div className="flex flex-col gap-1"> | |
| <button | |
| type="button" | |
| onClick={() => setIsOpen((prev) => !prev)} | |
| className="flex items-center justify-between w-full px-3 py-2 rounded-lg | |
| text-xs font-semibold uppercase tracking-widest | |
| transition-colors duration-150 hover:bg-white/5" | |
| style={{ color: "rgba(235,237,247,0.5)" }} | |
| aria-expanded={isOpen} | |
| > | |
| <span>{title}</span> | |
| <svg | |
| className="w-3 h-3 transition-transform duration-200" | |
| style={{ transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)" }} | |
| viewBox="0 0 12 12" | |
| fill="none" | |
| aria-hidden="true" | |
| > | |
| <path | |
| d="M2 4l4 4 4-4" | |
| stroke="currentColor" | |
| strokeWidth="1.5" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| /> | |
| </svg> | |
| </button> | |
| {isOpen && ( | |
| <div className="flex flex-col gap-0.5 mt-0.5">{children}</div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 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 ( | |
| <aside | |
| className="flex flex-col h-full w-64 shrink-0 overflow-y-auto" | |
| style={{ | |
| background: | |
| "linear-gradient(180deg, rgba(84,47,232,0.18) 0%, rgba(11,11,12,1) 55%)", | |
| borderRight: "1px solid rgba(255,255,255,0.05)", | |
| color: "#EBEDF7", | |
| }} | |
| > | |
| {/* ------------------------------------------------------------------ */} | |
| {/* Top action buttons */} | |
| {/* ------------------------------------------------------------------ */} | |
| {/* ------------------------------------------------------------------ */} | |
| {/* Divider */} | |
| {/* ------------------------------------------------------------------ */} | |
| <div | |
| className="mx-4 mb-3" | |
| style={{ borderTop: "1px solid rgba(255,255,255,0.07)" }} | |
| /> | |
| {/* ------------------------------------------------------------------ */} | |
| {/* Selectors */} | |
| {/* ------------------------------------------------------------------ */} | |
| <div className="flex flex-col gap-3 px-4 pb-3"> | |
| {/* Profession selector — Requirement 9.1 */} | |
| <div className="flex flex-col gap-1.5"> | |
| <label | |
| htmlFor="sidebar-profession" | |
| className="text-xs font-semibold uppercase tracking-widest" | |
| style={{ color: "rgba(235,237,247,0.5)" }} | |
| > | |
| Profession | |
| </label> | |
| <select | |
| id="sidebar-profession" | |
| value={profession} | |
| onChange={(e) => handleProfessionChange(e.target.value)} | |
| className="w-full rounded-lg px-3 py-2 text-sm outline-none | |
| transition-colors duration-150 | |
| focus:ring-2 focus:ring-white/20" | |
| style={{ | |
| background: "rgba(255,255,255,0.07)", | |
| border: "1px solid rgba(255,255,255,0.12)", | |
| color: "#EBEDF7", | |
| }} | |
| > | |
| <option value="">-- please select --</option> | |
| {PROFESSIONS.map((p) => ( | |
| <option key={p} value={p}> | |
| {p} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| {/* Language selector — Requirement 9.3 */} | |
| <div className="flex flex-col gap-1.5"> | |
| <label | |
| htmlFor="sidebar-language" | |
| className="text-xs font-semibold uppercase tracking-widest" | |
| style={{ color: "rgba(235,237,247,0.5)" }} | |
| > | |
| Language | |
| </label> | |
| <select | |
| id="sidebar-language" | |
| value={language} | |
| onChange={(e) => onLanguageChange(e.target.value)} | |
| className="w-full rounded-lg px-3 py-2 text-sm outline-none | |
| transition-colors duration-150 | |
| focus:ring-2 focus:ring-white/20" | |
| style={{ | |
| background: "rgba(255,255,255,0.07)", | |
| border: "1px solid rgba(255,255,255,0.12)", | |
| color: "#EBEDF7", | |
| }} | |
| > | |
| {Object.entries(SUPPORTED_LANGUAGES).map(([code, label]) => ( | |
| <option key={code} value={code}> | |
| {label} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </div> | |
| {/* ------------------------------------------------------------------ */} | |
| {/* Profession-change notice — Requirement 9.5 */} | |
| {/* ------------------------------------------------------------------ */} | |
| {showProfessionNotice && ( | |
| <div | |
| className="mx-4 mb-3 rounded-lg p-3 text-xs flex flex-col gap-2" | |
| role="status" | |
| style={{ | |
| background: "rgba(84,47,232,0.25)", | |
| border: "1px solid rgba(84,47,232,0.5)", | |
| color: "#EBEDF7", | |
| }} | |
| > | |
| <p> | |
| The current answer was generated without the{" "} | |
| <strong>{profession}</strong> profession context. Re-run to get a | |
| profession-specific response. | |
| </p> | |
| <div className="flex gap-2"> | |
| <button | |
| type="button" | |
| onClick={handleRerun} | |
| className="flex-1 rounded-md py-1.5 text-xs font-semibold | |
| transition-colors duration-150 hover:opacity-90" | |
| style={{ | |
| background: "linear-gradient(135deg, #542FE8 0%, #1E3798 100%)", | |
| color: "#FFFFFF", | |
| }} | |
| > | |
| Re-run with {profession} | |
| </button> | |
| <button | |
| type="button" | |
| onClick={handleDismissNotice} | |
| className="rounded-md px-2 py-1.5 text-xs transition-colors | |
| duration-150 hover:bg-white/10" | |
| style={{ color: "rgba(235,237,247,0.6)" }} | |
| aria-label="Dismiss notice" | |
| > | |
| ✕ | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* ------------------------------------------------------------------ */} | |
| {/* Divider */} | |
| {/* ------------------------------------------------------------------ */} | |
| <div | |
| className="mx-4 mb-3" | |
| style={{ borderTop: "1px solid rgba(255,255,255,0.07)" }} | |
| /> | |
| {/* ------------------------------------------------------------------ */} | |
| {/* Bottom block — new conversation + saved responses + past conversations */} | |
| {/* ------------------------------------------------------------------ */} | |
| <div className="flex flex-col gap-2 px-2 pb-4 mt-auto"> | |
| {/* New conversation */} | |
| <button | |
| type="button" | |
| onClick={handleNewConversation} | |
| className="flex items-center gap-2 w-full rounded-lg px-3 py-2.5 | |
| text-sm font-medium transition-colors duration-150 | |
| hover:bg-white/10 active:bg-white/15" | |
| style={{ color: "#EBEDF7" }} | |
| > | |
| <svg className="w-4 h-4 shrink-0" viewBox="0 0 16 16" fill="none" aria-hidden="true"> | |
| <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> | |
| </svg> | |
| New conversation | |
| </button> | |
| <div style={{ borderTop: "1px solid rgba(255,255,255,0.07)" }} className="my-1" /> | |
| {/* Saved responses */} | |
| <CollapsibleSection title="Saved responses" defaultOpen={true}> | |
| {savedResponses.length === 0 ? ( | |
| <p className="px-3 py-2 text-xs" style={{ color: "rgba(235,237,247,0.35)" }}> | |
| No saved responses yet. | |
| </p> | |
| ) : ( | |
| savedResponses.map((sr) => ( | |
| <SavedResponseRow | |
| key={sr.id} | |
| savedResponse={sr} | |
| onSelect={onSelectSavedResponse} | |
| onDelete={onDeleteSavedResponse} | |
| /> | |
| )) | |
| )} | |
| </CollapsibleSection> | |
| <button | |
| type="button" | |
| onClick={() => exportAllSavedResponses(savedResponses)} | |
| disabled={savedResponses.length === 0} | |
| className="flex items-center gap-2 w-full rounded-lg px-3 py-2 | |
| text-sm font-medium transition-colors duration-150 | |
| hover:bg-white/10 active:bg-white/15 | |
| disabled:opacity-40 disabled:cursor-not-allowed" | |
| style={{ color: "#EBEDF7" }} | |
| title={savedResponses.length === 0 ? "No saved responses to download" : "Download all saved responses as Markdown"} | |
| > | |
| <svg className="w-4 h-4 shrink-0" viewBox="0 0 16 16" fill="none" aria-hidden="true"> | |
| <path d="M8 2v8M5 7l3 3 3-3M3 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> | |
| </svg> | |
| Download saved responses | |
| </button> | |
| {/* Divider between saved responses and past conversations */} | |
| <div style={{ borderTop: "1px solid rgba(255,255,255,0.07)" }} className="my-1" /> | |
| {/* Past conversations */} | |
| <CollapsibleSection title="Past conversations" defaultOpen={true}> | |
| {conversations.length === 0 ? ( | |
| <p className="px-3 py-2 text-xs" style={{ color: "rgba(235,237,247,0.35)" }}> | |
| No conversations yet. | |
| </p> | |
| ) : ( | |
| conversations.map((conv) => ( | |
| <ConversationRow | |
| key={conv.id} | |
| conversation={conv} | |
| onSelect={onSelectConversation} | |
| onDelete={onDeleteConversation} | |
| /> | |
| )) | |
| )} | |
| </CollapsibleSection> | |
| </div> | |
| </aside> | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // 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 ( | |
| <div | |
| className="group flex items-start gap-1 rounded-lg px-2 py-1.5 | |
| transition-colors duration-150 hover:bg-white/8" | |
| > | |
| <button | |
| type="button" | |
| onClick={() => onSelect(conversation.id)} | |
| className="flex-1 text-left text-sm leading-snug break-words transition-colors duration-150" | |
| style={{ color: "#EBEDF7" }} | |
| > | |
| {fullQuestion} | |
| </button> | |
| <button | |
| type="button" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onDelete(conversation.id); | |
| }} | |
| className="shrink-0 rounded p-1 opacity-0 group-hover:opacity-100 | |
| transition-opacity duration-150 hover:bg-white/10" | |
| style={{ color: "rgba(235,237,247,0.5)" }} | |
| aria-label={`Delete conversation: ${conversation.title}`} | |
| title="Delete conversation" | |
| > | |
| <svg | |
| className="w-3.5 h-3.5" | |
| viewBox="0 0 14 14" | |
| fill="none" | |
| aria-hidden="true" | |
| > | |
| <path | |
| d="M2 2l10 10M12 2L2 12" | |
| stroke="currentColor" | |
| strokeWidth="1.5" | |
| strokeLinecap="round" | |
| /> | |
| </svg> | |
| </button> | |
| </div> | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // SavedResponseRow | |
| // --------------------------------------------------------------------------- | |
| interface SavedResponseRowProps { | |
| savedResponse: SavedResponse; | |
| onSelect: (id: string) => void; | |
| onDelete: (id: string) => void; | |
| } | |
| function SavedResponseRow({ | |
| savedResponse, | |
| onSelect, | |
| onDelete, | |
| }: SavedResponseRowProps) { | |
| return ( | |
| <div | |
| className="group flex items-start gap-1 rounded-lg px-2 py-1.5 | |
| transition-colors duration-150 hover:bg-white/8" | |
| > | |
| <button | |
| type="button" | |
| onClick={() => onSelect(savedResponse.id)} | |
| className="flex-1 text-left text-sm leading-snug break-words transition-colors duration-150" | |
| style={{ color: "#EBEDF7" }} | |
| > | |
| {savedResponse.question} | |
| </button> | |
| <button | |
| type="button" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onDelete(savedResponse.id); | |
| }} | |
| className="shrink-0 rounded p-1 mt-0.5 opacity-0 group-hover:opacity-100 | |
| transition-opacity duration-150 hover:bg-white/10" | |
| style={{ color: "rgba(235,237,247,0.5)" }} | |
| aria-label={`Delete saved response: ${savedResponse.question}`} | |
| title="Delete saved response" | |
| > | |
| <svg | |
| className="w-3.5 h-3.5" | |
| viewBox="0 0 14 14" | |
| fill="none" | |
| aria-hidden="true" | |
| > | |
| <path | |
| d="M2 2l10 10M12 2L2 12" | |
| stroke="currentColor" | |
| strokeWidth="1.5" | |
| strokeLinecap="round" | |
| /> | |
| </svg> | |
| </button> | |
| </div> | |
| ); | |
| } | |