File size: 11,233 Bytes
b52a2ce
2863b71
25fcd9d
 
92128df
846285c
2f8db1b
e0b5fb0
b52a2ce
2863b71
b52a2ce
2863b71
 
 
 
 
 
 
b52a2ce
cd2318c
92128df
0ef5c60
2f8db1b
 
 
92128df
2f8db1b
 
e0b5fb0
8af5a21
b52a2ce
92128df
 
2f8db1b
92128df
 
 
 
 
846285c
c4c4e16
8fdae75
 
e0b5fb0
 
 
 
 
 
 
2863b71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92128df
846285c
 
b52a2ce
 
 
 
 
2863b71
 
 
 
 
 
 
 
 
 
 
b52a2ce
2863b71
 
 
 
b52a2ce
 
2863b71
e0b5fb0
2863b71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8af5a21
e0b5fb0
2863b71
 
 
 
 
 
 
 
 
 
 
 
 
e0b5fb0
 
 
8af5a21
92128df
 
e0b5fb0
 
846285c
92128df
 
 
2f8db1b
846285c
25fcd9d
0ef5c60
92128df
 
 
25fcd9d
0ef5c60
846285c
92128df
 
846285c
 
25fcd9d
 
2863b71
e0b5fb0
 
2863b71
 
 
 
 
 
e0b5fb0
25fcd9d
2f8db1b
2863b71
 
 
 
 
846285c
2863b71
 
846285c
2863b71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
846285c
2863b71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f8db1b
2863b71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
846285c
e0b5fb0
 
846285c
 
 
 
 
cd2318c
25fcd9d
846285c
 
 
2863b71
 
92128df
8fdae75
e0b5fb0
 
846285c
8fdae75
92128df
e0b5fb0
2f8db1b
846285c
92128df
 
e0b5fb0
92128df
846285c
92128df
 
 
 
e0b5fb0
92128df
e0b5fb0
846285c
 
92128df
 
846285c
 
92128df
 
846285c
92128df
 
 
 
 
e0b5fb0
 
 
2f8db1b
92128df
 
25fcd9d
92128df
846285c
e0b5fb0
92128df
 
 
 
 
846285c
 
92128df
 
 
 
25fcd9d
 
92128df
 
 
 
 
2f8db1b
0ef5c60
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
// 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>
  );
}