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 (