Просмотр исходного кода

style(studio): 精简 sidebar 视觉 + rename dialog 紧凑化

Sidebar 书/会话树:
- 默认全部折叠,只有当前 activePage 对应的书会在点击时展开;展开/折叠
  不再触发 /sessions 请求(靠 sessionIdsByBook 缓存,新展开书没加载过
  才拉一次)
- 书名行去掉背景高亮,改 FolderOpen 图标 + 纯文本,ChevronRight 只作
  轻量折叠指示
- 会话行去掉左侧圆点和右侧 ● 选中符号,改为纯文本 + 右对齐相对时间
  (刚刚 / 3 小时 / 6 天 / 2 个月);流式中显示 Loader2 替代时间
- 首条消息展示直接读 title 字段(后端写入),只在极短时间窗内有 title
  未同步时 fallback 到本地 messages 里第一条用户消息

Rename dialog:
- 去掉"手动标题会覆盖自动生成标题,后续不再被 AI 改写"的描述(AI 生成
  逻辑已删,描述也过时)
- 标题改 sans-serif 小字,整体尺寸收窄到 360px,内边距和间距收紧
- 输入框 focus 边框改柔和色,不再红色高亮
- 取消按钮改文字链接风格,保存按钮小而收敛

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun 6 месяцев назад
Родитель
Сommit
e4663bcbd8
1 измененных файлов с 48 добавлено и 46 удалено
  1. 48 46
      packages/studio/src/components/Sidebar.tsx

+ 48 - 46
packages/studio/src/components/Sidebar.tsx

@@ -8,7 +8,6 @@ import { ConfirmDialog } from "./ConfirmDialog";
 import {
 import {
   Dialog,
   Dialog,
   DialogContent,
   DialogContent,
-  DialogDescription,
   DialogFooter,
   DialogFooter,
   DialogHeader,
   DialogHeader,
   DialogTitle,
   DialogTitle,
@@ -75,7 +74,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
   const loadSessionList = useChatStore((s) => s.loadSessionList);
   const loadSessionList = useChatStore((s) => s.loadSessionList);
   const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
   const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
   const activateSession = useChatStore((s) => s.activateSession);
   const activateSession = useChatStore((s) => s.activateSession);
-  const createSession = useChatStore((s) => s.createSession);
+  const createDraftSession = useChatStore((s) => s.createDraftSession);
   const renameSession = useChatStore((s) => s.renameSession);
   const renameSession = useChatStore((s) => s.renameSession);
   const deleteSession = useChatStore((s) => s.deleteSession);
   const deleteSession = useChatStore((s) => s.deleteSession);
   const [renameTarget, setRenameTarget] = useState<{ sessionId: string; currentTitle: string } | null>(null);
   const [renameTarget, setRenameTarget] = useState<{ sessionId: string; currentTitle: string } | null>(null);
@@ -96,19 +95,25 @@ export function Sidebar({ nav, activePage, sse, t }: {
     }
     }
   }, [refetchBooks, refetchDaemon, sse.messages]);
   }, [refetchBooks, refetchDaemon, sse.messages]);
 
 
+  // bookDataVersion 变化(外部数据信号)时才重拉当前已展开书的 session 列表;
+  // 展开/折叠本身不触发请求(展开由 toggleBook 驱动,已带"首次加载"判断)。
   useEffect(() => {
   useEffect(() => {
     for (const bookId of expandedBooks) {
     for (const bookId of expandedBooks) {
       void loadSessionList(bookId);
       void loadSessionList(bookId);
     }
     }
-  }, [bookDataVersion, expandedBooks, loadSessionList]);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [bookDataVersion, loadSessionList]);
 
 
   const toggleBook = (bookId: string) => {
   const toggleBook = (bookId: string) => {
     setExpandedBooks((prev) => {
     setExpandedBooks((prev) => {
       const next = new Set(prev);
       const next = new Set(prev);
       if (next.has(bookId)) {
       if (next.has(bookId)) {
         next.delete(bookId);
         next.delete(bookId);
-      } else {
-        next.add(bookId);
+        return next;
+      }
+      next.add(bookId);
+      // 首次展开才拉:已有 sessionIdsByBook 数据就直接用缓存
+      if (sessionIdsByBook[bookId] === undefined) {
         void loadSessionList(bookId);
         void loadSessionList(bookId);
       }
       }
       return next;
       return next;
@@ -134,11 +139,12 @@ export function Sidebar({ nav, activePage, sse, t }: {
     void loadSessionDetail(sessionId);
     void loadSessionDetail(sessionId);
   };
   };
 
 
-  const handleCreateSession = async (bookId: string) => {
+  const handleCreateSession = (bookId: string) => {
+    // 前端创建草稿会话:对话区立即变空,但 session 文件不落盘;
+    // 发第一条消息时 sendMessage 会调 POST /sessions 真正创建。
     setExpandedBooks((prev) => new Set(prev).add(bookId));
     setExpandedBooks((prev) => new Set(prev).add(bookId));
-    const sessionId = await createSession(bookId);
+    createDraftSession(bookId);
     nav.toBook(bookId);
     nav.toBook(bookId);
-    await loadSessionDetail(sessionId);
   };
   };
 
 
   const handleRenameConfirm = async () => {
   const handleRenameConfirm = async () => {
@@ -221,7 +227,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
                     <div className="mt-0.5">
                     <div className="mt-0.5">
                       {bookSessions.map((session) => {
                       {bookSessions.map((session) => {
                         const isActiveSession = isActiveBook && activeSessionId === session.sessionId;
                         const isActiveSession = isActiveBook && activeSessionId === session.sessionId;
-                        const label = getSessionLabel(session.sessionId, session.title);
+                        const label = getSessionLabel(session);
                         return (
                         return (
                           <div
                           <div
                             key={session.sessionId}
                             key={session.sessionId}
@@ -387,40 +393,35 @@ export function Sidebar({ nav, activePage, sse, t }: {
           }
           }
         }}
         }}
       >
       >
-        <DialogContent showCloseButton={false}>
-          <DialogHeader>
-            <DialogTitle>重命名会话</DialogTitle>
-            <DialogDescription>
-              手动标题会覆盖自动生成标题,后续不再被 AI 改写。
-            </DialogDescription>
+        <DialogContent
+          showCloseButton={false}
+          className="sm:max-w-[360px] p-4 gap-3"
+        >
+          <DialogHeader className="space-y-0 gap-0">
+            <DialogTitle className="font-sans text-sm font-medium">重命名会话</DialogTitle>
           </DialogHeader>
           </DialogHeader>
-          <div className="space-y-2">
-            <label className="text-xs font-medium text-muted-foreground" htmlFor="session-rename-input">
-              会话标题
-            </label>
-            <input
-              id="session-rename-input"
-              autoFocus
-              value={renameValue}
-              onChange={(event) => setRenameValue(event.target.value)}
-              onKeyDown={(event) => {
-                if (event.key === "Enter") {
-                  event.preventDefault();
-                  void handleRenameConfirm();
-                }
-              }}
-              placeholder="输入新标题"
-              className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
-            />
-          </div>
-          <DialogFooter>
+          <input
+            id="session-rename-input"
+            autoFocus
+            value={renameValue}
+            onChange={(event) => setRenameValue(event.target.value)}
+            onKeyDown={(event) => {
+              if (event.key === "Enter") {
+                event.preventDefault();
+                void handleRenameConfirm();
+              }
+            }}
+            placeholder="输入新标题"
+            className="w-full rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-border"
+          />
+          <DialogFooter className="gap-1 sm:gap-1">
             <button
             <button
               type="button"
               type="button"
               onClick={() => {
               onClick={() => {
                 setRenameTarget(null);
                 setRenameTarget(null);
                 setRenameValue("");
                 setRenameValue("");
               }}
               }}
-              className="px-4 py-2 text-sm font-medium rounded-xl bg-secondary text-foreground hover:bg-secondary/80 transition-all border border-border/50"
+              className="px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
             >
             >
               取消
               取消
             </button>
             </button>
@@ -428,7 +429,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
               type="button"
               type="button"
               onClick={() => void handleRenameConfirm()}
               onClick={() => void handleRenameConfirm()}
               disabled={!renameValue.trim()}
               disabled={!renameValue.trim()}
-              className="px-4 py-2 text-sm font-bold rounded-xl bg-primary text-primary-foreground transition-all disabled:opacity-40"
+              className="px-3 py-1 text-xs font-medium rounded-md bg-foreground text-background hover:opacity-90 transition-opacity disabled:opacity-30"
             >
             >
               保存
               保存
             </button>
             </button>
@@ -450,15 +451,16 @@ export function Sidebar({ nav, activePage, sse, t }: {
   );
   );
 }
 }
 
 
-function getSessionLabel(sessionId: string, title: string | null): string {
-  if (title) return title;
-  const rawTs = Number(sessionId.split("-")[0]);
-  if (!Number.isFinite(rawTs)) return "新会话";
-  const formatted = new Date(rawTs).toLocaleTimeString("zh-CN", {
-    hour: "2-digit",
-    minute: "2-digit",
-  });
-  return `新会话 · ${formatted}`;
+function getSessionLabel(session: { sessionId: string; title: string | null; messages: ReadonlyArray<{ role: string; content: string }> }): string {
+  if (session.title) return session.title;
+  // 后端会在第一条用户消息发送时立即把消息内容持久化为占位标题。
+  // 这里处理的是"已有消息但标题还没同步回来"的短暂中间态(乐观显示)。
+  const firstUserMsg = session.messages.find((m) => m.role === "user")?.content?.trim();
+  if (firstUserMsg) {
+    const oneLine = firstUserMsg.replace(/\s+/g, " ");
+    return oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
+  }
+  return "新会话";
 }
 }
 
 
 function formatRelativeTime(sessionId: string): string {
 function formatRelativeTime(sessionId: string): string {