Spaces:
Sleeping
Sleeping
| // 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: | |
| {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: | |
| {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> | |
| ); | |
| } | |