Spaces:
Sleeping
Sleeping
Update web/src/components/LeftSidebar.tsx
Browse files- web/src/components/LeftSidebar.tsx +149 -83
web/src/components/LeftSidebar.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
// web/src/components/LeftSidebar.tsx
|
| 2 |
-
import React, { useMemo, useState } from "react";
|
| 3 |
import { Button } from "./ui/button";
|
| 4 |
import { Input } from "./ui/input";
|
| 5 |
import { Separator } from "./ui/separator";
|
|
@@ -7,13 +7,15 @@ import { Bookmark, Trash2, Edit2, Check, X as XIcon } from "lucide-react";
|
|
| 7 |
|
| 8 |
import type { SavedChat, Workspace } from "../App";
|
| 9 |
|
| 10 |
-
//
|
| 11 |
-
// 所以这里不强依赖 CourseDirectoryItem 结构,做宽松兼容。
|
| 12 |
type AnyCourse = {
|
| 13 |
-
id: string;
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
};
|
| 18 |
|
| 19 |
type Props = {
|
|
@@ -25,10 +27,7 @@ type Props = {
|
|
| 25 |
currentWorkspaceId: string;
|
| 26 |
workspaces: Workspace[];
|
| 27 |
|
| 28 |
-
// ✅ 当前选中的课程(My Space 用它)
|
| 29 |
selectedCourse: string;
|
| 30 |
-
|
| 31 |
-
// ✅ 允许两种来源:CourseInfo[] 或 CourseDirectoryItem[]
|
| 32 |
availableCourses: AnyCourse[];
|
| 33 |
};
|
| 34 |
|
|
@@ -49,16 +48,30 @@ function gmailComposeLink(email: string, subject?: string, body?: string) {
|
|
| 49 |
return `https://mail.google.com/mail/?view=cm&fs=1&${to}${su}${bd}`;
|
| 50 |
}
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
const [editingId, setEditingId] = useState<string | null>(null);
|
| 63 |
const [draftTitle, setDraftTitle] = useState("");
|
| 64 |
|
|
@@ -67,27 +80,56 @@ export function LeftSidebar({
|
|
| 67 |
[workspaces, currentWorkspaceId]
|
| 68 |
);
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
/**
|
| 71 |
-
*
|
| 72 |
-
* 优先
|
| 73 |
-
*
|
|
|
|
| 74 |
*/
|
| 75 |
const courseInfo: AnyCourse | null = useMemo(() => {
|
| 76 |
-
const
|
| 77 |
-
if (selId) {
|
| 78 |
-
const hit = availableCourses.find((c) => String(c.id) === selId);
|
| 79 |
-
if (hit) return hit;
|
| 80 |
-
}
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
const
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
return null;
|
|
@@ -116,60 +158,85 @@ export function LeftSidebar({
|
|
| 116 |
cancelRename();
|
| 117 |
};
|
| 118 |
|
| 119 |
-
//
|
| 120 |
const instructorName = courseInfo?.instructor?.name ?? "N/A";
|
| 121 |
const instructorEmail = courseInfo?.instructor?.email?.trim() || "";
|
| 122 |
-
|
| 123 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
return (
|
| 126 |
<div className="h-full w-full flex flex-col min-h-0">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
{/* ================= Course Info(不滚动) ================= */}
|
| 128 |
-
|
| 129 |
-
<div className="
|
| 130 |
-
<div className="font-semibold text-base">{courseInfo.name}</div>
|
| 131 |
-
|
| 132 |
-
<div className="text-sm text-muted-foreground">
|
| 133 |
-
Instructor:
|
| 134 |
-
{instructorEmail ? (
|
| 135 |
-
<a
|
| 136 |
-
href={gmailComposeLink(
|
| 137 |
-
instructorEmail,
|
| 138 |
-
`[Clare] Question about ${courseInfo.name}`,
|
| 139 |
-
`Hi ${instructorName},\n\nI have a question about ${courseInfo.name}:\n\n(Write your question here)\n\nThanks,\n`
|
| 140 |
-
)}
|
| 141 |
-
target="_blank"
|
| 142 |
-
rel="noopener noreferrer"
|
| 143 |
-
className="text-primary hover:underline"
|
| 144 |
-
>
|
| 145 |
-
{instructorName}
|
| 146 |
-
</a>
|
| 147 |
-
) : (
|
| 148 |
-
<span className="text-muted-foreground/60">{instructorName}</span>
|
| 149 |
-
)}
|
| 150 |
-
</div>
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
) : (
|
| 168 |
-
<span className="text-muted-foreground/60">{taName}</span>
|
| 169 |
-
)}
|
| 170 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</div>
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
{/* ✅ Info / Saved Chat 分割线:固定 #ECECF1 */}
|
| 175 |
<Separator className="flex-shrink-0 bg-[#ECECF1]" />
|
|
@@ -183,7 +250,8 @@ export function LeftSidebar({
|
|
| 183 |
<Separator className="flex-shrink-0" />
|
| 184 |
|
| 185 |
{/* ================= Saved Chat List(唯一滚动区) ================= */}
|
| 186 |
-
|
|
|
|
| 187 |
{sortedChats.length === 0 ? (
|
| 188 |
<div className="text-sm text-muted-foreground text-center py-10">
|
| 189 |
No saved chats yet
|
|
@@ -198,7 +266,6 @@ export function LeftSidebar({
|
|
| 198 |
return (
|
| 199 |
<div key={chat.id} className="rounded-xl border bg-card px-4 py-3">
|
| 200 |
<div className="flex items-start justify-between gap-3">
|
| 201 |
-
{/* 左侧 */}
|
| 202 |
<div className="flex-1 min-w-0">
|
| 203 |
{isEditing ? (
|
| 204 |
<div className="space-y-2">
|
|
@@ -228,7 +295,6 @@ export function LeftSidebar({
|
|
| 228 |
)}
|
| 229 |
</div>
|
| 230 |
|
| 231 |
-
{/* 右侧按钮 */}
|
| 232 |
{!isEditing && (
|
| 233 |
<div className="flex gap-2 flex-shrink-0">
|
| 234 |
<Button variant="ghost" size="icon" onClick={() => startRename(chat)}>
|
|
|
|
| 1 |
// web/src/components/LeftSidebar.tsx
|
| 2 |
+
import React, { useMemo, useState, useEffect } from "react";
|
| 3 |
import { Button } from "./ui/button";
|
| 4 |
import { Input } from "./ui/input";
|
| 5 |
import { Separator } from "./ui/separator";
|
|
|
|
| 7 |
|
| 8 |
import type { SavedChat, Workspace } from "../App";
|
| 9 |
|
| 10 |
+
// 兼容 CourseDirectoryItem / CourseInfo 两种形态(字段可能不完全一致)
|
|
|
|
| 11 |
type AnyCourse = {
|
| 12 |
+
id?: string;
|
| 13 |
+
courseId?: string;
|
| 14 |
+
name?: string;
|
| 15 |
+
title?: string;
|
| 16 |
+
instructor?: { name?: string; email?: string };
|
| 17 |
+
teachingAssistant?: { name?: string; email?: string };
|
| 18 |
+
ta?: { name?: string; email?: string };
|
| 19 |
};
|
| 20 |
|
| 21 |
type Props = {
|
|
|
|
| 27 |
currentWorkspaceId: string;
|
| 28 |
workspaces: Workspace[];
|
| 29 |
|
|
|
|
| 30 |
selectedCourse: string;
|
|
|
|
|
|
|
| 31 |
availableCourses: AnyCourse[];
|
| 32 |
};
|
| 33 |
|
|
|
|
| 48 |
return `https://mail.google.com/mail/?view=cm&fs=1&${to}${su}${bd}`;
|
| 49 |
}
|
| 50 |
|
| 51 |
+
function getCourseId(c: AnyCourse) {
|
| 52 |
+
return String(c.id ?? c.courseId ?? "").trim();
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function getCourseName(c: AnyCourse) {
|
| 56 |
+
return String(c.name ?? c.title ?? "").trim();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function normalize(s: string) {
|
| 60 |
+
return s.trim().toLowerCase();
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export function LeftSidebar(props: Props) {
|
| 64 |
+
const {
|
| 65 |
+
savedChats,
|
| 66 |
+
onLoadChat,
|
| 67 |
+
onDeleteSavedChat,
|
| 68 |
+
onRenameSavedChat,
|
| 69 |
+
currentWorkspaceId,
|
| 70 |
+
workspaces,
|
| 71 |
+
selectedCourse,
|
| 72 |
+
availableCourses,
|
| 73 |
+
} = props;
|
| 74 |
+
|
| 75 |
const [editingId, setEditingId] = useState<string | null>(null);
|
| 76 |
const [draftTitle, setDraftTitle] = useState("");
|
| 77 |
|
|
|
|
| 80 |
[workspaces, currentWorkspaceId]
|
| 81 |
);
|
| 82 |
|
| 83 |
+
// Debug:确认这里是否真的在运行(开发态会看到 console)
|
| 84 |
+
useEffect(() => {
|
| 85 |
+
// eslint-disable-next-line no-console
|
| 86 |
+
console.log("[LeftSidebar] render", {
|
| 87 |
+
currentWorkspaceId,
|
| 88 |
+
selectedCourse,
|
| 89 |
+
availableCoursesLen: availableCourses?.length ?? 0,
|
| 90 |
+
workspaceCourseInfo: (currentWorkspace as any)?.courseInfo,
|
| 91 |
+
});
|
| 92 |
+
}, [currentWorkspaceId, selectedCourse, availableCourses, currentWorkspace]);
|
| 93 |
+
|
| 94 |
/**
|
| 95 |
+
* 选课命中策略:
|
| 96 |
+
* 1) 优先 selectedCourse(My Space 的 source of truth)
|
| 97 |
+
* 2) 再用 workspace.courseInfo(group workspace 兜底)
|
| 98 |
+
* 3) 支持 id / courseId,name / title 多字段匹配
|
| 99 |
*/
|
| 100 |
const courseInfo: AnyCourse | null = useMemo(() => {
|
| 101 |
+
const sel = String(selectedCourse || "").trim();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
if (sel) {
|
| 104 |
+
// 先按 id/courseId 精确匹配
|
| 105 |
+
const byId = availableCourses.find((c) => getCourseId(c) === sel);
|
| 106 |
+
if (byId) return byId;
|
| 107 |
+
|
| 108 |
+
// 再按 name/title 精确匹配(有时 selectedCourse 传的是 name)
|
| 109 |
+
const byName = availableCourses.find((c) => getCourseName(c) === sel);
|
| 110 |
+
if (byName) return byName;
|
| 111 |
+
|
| 112 |
+
// 最后做一个宽松匹配(小写)
|
| 113 |
+
const selN = normalize(sel);
|
| 114 |
+
const loose = availableCourses.find(
|
| 115 |
+
(c) => normalize(getCourseId(c)) === selN || normalize(getCourseName(c)) === selN
|
| 116 |
+
);
|
| 117 |
+
if (loose) return loose;
|
| 118 |
}
|
| 119 |
|
| 120 |
+
const wsCourse = (currentWorkspace as any)?.courseInfo as AnyCourse | undefined;
|
| 121 |
+
if (wsCourse) {
|
| 122 |
+
const wsId = getCourseId(wsCourse);
|
| 123 |
+
if (wsId) {
|
| 124 |
+
const hit = availableCourses.find((c) => getCourseId(c) === wsId);
|
| 125 |
+
return hit ?? wsCourse;
|
| 126 |
+
}
|
| 127 |
+
const wsName = getCourseName(wsCourse);
|
| 128 |
+
if (wsName) {
|
| 129 |
+
const hit = availableCourses.find((c) => getCourseName(c) === wsName);
|
| 130 |
+
return hit ?? wsCourse;
|
| 131 |
+
}
|
| 132 |
+
return wsCourse;
|
| 133 |
}
|
| 134 |
|
| 135 |
return null;
|
|
|
|
| 158 |
cancelRename();
|
| 159 |
};
|
| 160 |
|
| 161 |
+
// 兼容 TA 字段不同命名:teachingAssistant / ta
|
| 162 |
const instructorName = courseInfo?.instructor?.name ?? "N/A";
|
| 163 |
const instructorEmail = courseInfo?.instructor?.email?.trim() || "";
|
| 164 |
+
|
| 165 |
+
const taObj = courseInfo?.teachingAssistant ?? courseInfo?.ta;
|
| 166 |
+
const taName = taObj?.name ?? "N/A";
|
| 167 |
+
const taEmail = taObj?.email?.trim() || "";
|
| 168 |
+
|
| 169 |
+
const courseTitle = getCourseName(courseInfo ?? {}) || "(No course matched)";
|
| 170 |
|
| 171 |
return (
|
| 172 |
<div className="h-full w-full flex flex-col min-h-0">
|
| 173 |
+
{/* === 强制可见:用来确认你改的文件生效 === */}
|
| 174 |
+
<div className="px-4 pt-2 text-xs text-red-600 flex-shrink-0">
|
| 175 |
+
LEFTSIDEBAR ACTIVE
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
{/* ================= Course Info(不滚动) ================= */}
|
| 179 |
+
<div className="px-4 pt-3 pb-3 flex-shrink-0 space-y-2">
|
| 180 |
+
<div className="font-semibold text-base">{courseTitle}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
+
{/* 如果没命中 courseInfo,直接把原因展示出来(这才是你现在缺的可观测性) */}
|
| 183 |
+
{!courseInfo && (
|
| 184 |
+
<div className="text-xs text-muted-foreground space-y-1">
|
| 185 |
+
<div>
|
| 186 |
+
courseInfo not found.
|
| 187 |
+
</div>
|
| 188 |
+
<div>
|
| 189 |
+
selectedCourse: <span className="font-medium">{String(selectedCourse || "")}</span>
|
| 190 |
+
</div>
|
| 191 |
+
<div>
|
| 192 |
+
availableCourses: <span className="font-medium">{availableCourses?.length ?? 0}</span>
|
| 193 |
+
</div>
|
| 194 |
+
<div>
|
| 195 |
+
currentWorkspaceId: <span className="font-medium">{currentWorkspaceId}</span>
|
| 196 |
+
</div>
|
|
|
|
|
|
|
|
|
|
| 197 |
</div>
|
| 198 |
+
)}
|
| 199 |
+
|
| 200 |
+
<div className="text-sm text-muted-foreground">
|
| 201 |
+
Instructor:
|
| 202 |
+
{instructorEmail ? (
|
| 203 |
+
<a
|
| 204 |
+
href={gmailComposeLink(
|
| 205 |
+
instructorEmail,
|
| 206 |
+
`[Clare] Question about ${courseTitle}`,
|
| 207 |
+
`Hi ${instructorName},\n\nI have a question about ${courseTitle}:\n\n(Write your question here)\n\nThanks,\n`
|
| 208 |
+
)}
|
| 209 |
+
target="_blank"
|
| 210 |
+
rel="noopener noreferrer"
|
| 211 |
+
className="text-primary hover:underline"
|
| 212 |
+
>
|
| 213 |
+
{instructorName}
|
| 214 |
+
</a>
|
| 215 |
+
) : (
|
| 216 |
+
<span className="text-muted-foreground/60">{instructorName}</span>
|
| 217 |
+
)}
|
| 218 |
</div>
|
| 219 |
+
|
| 220 |
+
<div className="text-sm text-muted-foreground">
|
| 221 |
+
TA:
|
| 222 |
+
{taEmail ? (
|
| 223 |
+
<a
|
| 224 |
+
href={gmailComposeLink(
|
| 225 |
+
taEmail,
|
| 226 |
+
`[Clare] Help request for ${courseTitle}`,
|
| 227 |
+
`Hi ${taName},\n\nI need help with ${courseTitle}:\n\n(Write your question here)\n\nThanks,\n`
|
| 228 |
+
)}
|
| 229 |
+
target="_blank"
|
| 230 |
+
rel="noopener noreferrer"
|
| 231 |
+
className="text-primary hover:underline"
|
| 232 |
+
>
|
| 233 |
+
{taName}
|
| 234 |
+
</a>
|
| 235 |
+
) : (
|
| 236 |
+
<span className="text-muted-foreground/60">{taName}</span>
|
| 237 |
+
)}
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
|
| 241 |
{/* ✅ Info / Saved Chat 分割线:固定 #ECECF1 */}
|
| 242 |
<Separator className="flex-shrink-0 bg-[#ECECF1]" />
|
|
|
|
| 250 |
<Separator className="flex-shrink-0" />
|
| 251 |
|
| 252 |
{/* ================= Saved Chat List(唯一滚动区) ================= */}
|
| 253 |
+
{/* 用你 CSS 里的 panelScroll,保证滚动隔离生效 */}
|
| 254 |
+
<div className="flex-1 min-h-0 px-4 py-3 space-y-3 panelScroll">
|
| 255 |
{sortedChats.length === 0 ? (
|
| 256 |
<div className="text-sm text-muted-foreground text-center py-10">
|
| 257 |
No saved chats yet
|
|
|
|
| 266 |
return (
|
| 267 |
<div key={chat.id} className="rounded-xl border bg-card px-4 py-3">
|
| 268 |
<div className="flex items-start justify-between gap-3">
|
|
|
|
| 269 |
<div className="flex-1 min-w-0">
|
| 270 |
{isEditing ? (
|
| 271 |
<div className="space-y-2">
|
|
|
|
| 295 |
)}
|
| 296 |
</div>
|
| 297 |
|
|
|
|
| 298 |
{!isEditing && (
|
| 299 |
<div className="flex gap-2 flex-shrink-0">
|
| 300 |
<Button variant="ghost" size="icon" onClick={() => startRename(chat)}>
|