hmc-rag / frontend /src /components /Sidebar.tsx
webmuppet
sidebar: show full question text for conversations and saved responses
741c3dc
Raw
History Blame Contribute Delete
18.6 kB
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>
);
}