AI_Agent_Final / web /src /components /LeftSidebar.tsx
SarahXia0405's picture
Update web/src/components/LeftSidebar.tsx
2863b71 verified
// web/src/components/LeftSidebar.tsx
import React, { useMemo, useState, useEffect } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Separator } from "./ui/separator";
import { Bookmark, Trash2, Edit2, Check, X as XIcon } from "lucide-react";
import type { SavedChat, Workspace } from "../App";
// 兼容 CourseDirectoryItem / CourseInfo 两种形态(字段可能不完全一致)
type AnyCourse = {
id?: string;
courseId?: string;
name?: string;
title?: string;
instructor?: { name?: string; email?: string };
teachingAssistant?: { name?: string; email?: string };
ta?: { name?: string; email?: string };
};
type Props = {
savedChats: SavedChat[];
onLoadChat: (chat: SavedChat) => void;
onDeleteSavedChat: (id: string) => void;
onRenameSavedChat: (id: string, newTitle: string) => void;
currentWorkspaceId: string;
workspaces: Workspace[];
selectedCourse: string;
availableCourses: AnyCourse[];
};
function formatSub(ts: any) {
if (!ts) return "";
try {
const d = typeof ts === "string" || typeof ts === "number" ? new Date(ts) : ts;
return d.toLocaleString();
} catch {
return "";
}
}
function gmailComposeLink(email: string, subject?: string, body?: string) {
const to = `to=${encodeURIComponent(email)}`;
const su = subject ? `&su=${encodeURIComponent(subject)}` : "";
const bd = body ? `&body=${encodeURIComponent(body)}` : "";
return `https://mail.google.com/mail/?view=cm&fs=1&${to}${su}${bd}`;
}
function getCourseId(c: AnyCourse) {
return String(c.id ?? c.courseId ?? "").trim();
}
function getCourseName(c: AnyCourse) {
return String(c.name ?? c.title ?? "").trim();
}
function normalize(s: string) {
return s.trim().toLowerCase();
}
export function LeftSidebar(props: Props) {
const {
savedChats,
onLoadChat,
onDeleteSavedChat,
onRenameSavedChat,
currentWorkspaceId,
workspaces,
selectedCourse,
availableCourses,
} = props;
const [editingId, setEditingId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
const currentWorkspace = useMemo(
() => workspaces.find((w) => w.id === currentWorkspaceId),
[workspaces, currentWorkspaceId]
);
// Debug:确认这里是否真的在运行(开发态会看到 console)
useEffect(() => {
// eslint-disable-next-line no-console
console.log("[LeftSidebar] render", {
currentWorkspaceId,
selectedCourse,
availableCoursesLen: availableCourses?.length ?? 0,
workspaceCourseInfo: (currentWorkspace as any)?.courseInfo,
});
}, [currentWorkspaceId, selectedCourse, availableCourses, currentWorkspace]);
/**
* 选课命中策略:
* 1) 优先 selectedCourse(My Space 的 source of truth)
* 2) 再用 workspace.courseInfo(group workspace 兜底)
* 3) 支持 id / courseId,name / title 多字段匹配
*/
const courseInfo: AnyCourse | null = useMemo(() => {
const sel = String(selectedCourse || "").trim();
if (sel) {
// 先按 id/courseId 精确匹配
const byId = availableCourses.find((c) => getCourseId(c) === sel);
if (byId) return byId;
// 再按 name/title 精确匹配(有时 selectedCourse 传的是 name)
const byName = availableCourses.find((c) => getCourseName(c) === sel);
if (byName) return byName;
// 最后做一个宽松匹配(小写)
const selN = normalize(sel);
const loose = availableCourses.find(
(c) => normalize(getCourseId(c)) === selN || normalize(getCourseName(c)) === selN
);
if (loose) return loose;
}
const wsCourse = (currentWorkspace as any)?.courseInfo as AnyCourse | undefined;
if (wsCourse) {
const wsId = getCourseId(wsCourse);
if (wsId) {
const hit = availableCourses.find((c) => getCourseId(c) === wsId);
return hit ?? wsCourse;
}
const wsName = getCourseName(wsCourse);
if (wsName) {
const hit = availableCourses.find((c) => getCourseName(c) === wsName);
return hit ?? wsCourse;
}
return wsCourse;
}
return null;
}, [availableCourses, currentWorkspace, selectedCourse]);
const sortedChats = useMemo(() => {
return [...savedChats].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
}, [savedChats]);
const startRename = (chat: SavedChat) => {
setEditingId(chat.id);
setDraftTitle(chat.title || "");
};
const cancelRename = () => {
setEditingId(null);
setDraftTitle("");
};
const commitRename = (id: string) => {
const next = draftTitle.trim();
if (!next) return;
onRenameSavedChat(id, next);
cancelRename();
};
// 兼容 TA 字段不同命名:teachingAssistant / ta
const instructorName = courseInfo?.instructor?.name ?? "N/A";
const instructorEmail = courseInfo?.instructor?.email?.trim() || "";
const taObj = courseInfo?.teachingAssistant ?? courseInfo?.ta;
const taName = taObj?.name ?? "N/A";
const taEmail = taObj?.email?.trim() || "";
const courseTitle = getCourseName(courseInfo ?? {}) || "(No course matched)";
return (
<div className="h-full w-full flex flex-col min-h-0">
{/* === 强制可见:用来确认你改的文件生效 === */}
<div className="px-4 pt-2 text-xs text-red-600 flex-shrink-0">
LEFTSIDEBAR ACTIVE
</div>
{/* ================= Course Info(不滚动) ================= */}
<div className="px-4 pt-3 pb-3 flex-shrink-0 space-y-2">
<div className="font-semibold text-base">{courseTitle}</div>
{/* 如果没命中 courseInfo,直接把原因展示出来(这才是你现在缺的可观测性) */}
{!courseInfo && (
<div className="text-xs text-muted-foreground space-y-1">
<div>
courseInfo not found.
</div>
<div>
selectedCourse: <span className="font-medium">{String(selectedCourse || "")}</span>
</div>
<div>
availableCourses: <span className="font-medium">{availableCourses?.length ?? 0}</span>
</div>
<div>
currentWorkspaceId: <span className="font-medium">{currentWorkspaceId}</span>
</div>
</div>
)}
<div className="text-sm text-muted-foreground">
Instructor:&nbsp;
{instructorEmail ? (
<a
href={gmailComposeLink(
instructorEmail,
`[Clare] Question about ${courseTitle}`,
`Hi ${instructorName},\n\nI have a question about ${courseTitle}:\n\n(Write your question here)\n\nThanks,\n`
)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{instructorName}
</a>
) : (
<span className="text-muted-foreground/60">{instructorName}</span>
)}
</div>
<div className="text-sm text-muted-foreground">
TA:&nbsp;
{taEmail ? (
<a
href={gmailComposeLink(
taEmail,
`[Clare] Help request for ${courseTitle}`,
`Hi ${taName},\n\nI need help with ${courseTitle}:\n\n(Write your question here)\n\nThanks,\n`
)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{taName}
</a>
) : (
<span className="text-muted-foreground/60">{taName}</span>
)}
</div>
</div>
{/* ✅ Info / Saved Chat 分割线:固定 #ECECF1 */}
<Separator className="flex-shrink-0 bg-[#ECECF1]" />
{/* ================= Saved Chat Header(不滚动) ================= */}
<div className="px-4 pt-4 pb-2 flex items-center gap-2 flex-shrink-0">
<Bookmark className="h-4 w-4" />
<h3 className="font-semibold">Saved Chat</h3>
</div>
<Separator className="flex-shrink-0" />
{/* ================= Saved Chat List(唯一滚动区) ================= */}
{/* 用你 CSS 里的 panelScroll,保证滚动隔离生效 */}
<div className="flex-1 min-h-0 px-4 py-3 space-y-3 panelScroll">
{sortedChats.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-10">
No saved chats yet
<br />
<span className="text-xs">Save conversations to view them here</span>
</div>
) : (
sortedChats.map((chat) => {
const isEditing = editingId === chat.id;
const sub = formatSub(chat.timestamp);
return (
<div key={chat.id} className="rounded-xl border bg-card px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="space-y-2">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") commitRename(chat.id);
if (e.key === "Escape") cancelRename();
}}
/>
<div className="flex gap-2">
<Button size="sm" onClick={() => commitRename(chat.id)}>
<Check className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={cancelRename}>
<XIcon className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<button className="text-left w-full" onClick={() => onLoadChat(chat)}>
<div className="font-medium truncate">{chat.title || "Untitled chat"}</div>
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
</button>
)}
</div>
{!isEditing && (
<div className="flex gap-2 flex-shrink-0">
<Button variant="ghost" size="icon" onClick={() => startRename(chat)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDeleteSavedChat(chat.id)}
className="hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
})
)}
</div>
</div>
);
}