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>
This commit is contained in:
fanghanjun 2026-04-17 02:27:37 -07:00 committed by Ma
parent 3824af7639
commit 7673f08499

View file

@ -8,7 +8,6 @@ import { ConfirmDialog } from "./ConfirmDialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@ -75,7 +74,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
const loadSessionList = useChatStore((s) => s.loadSessionList);
const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
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 deleteSession = useChatStore((s) => s.deleteSession);
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]);
// bookDataVersion 变化(外部数据信号)时才重拉当前已展开书的 session 列表;
// 展开/折叠本身不触发请求(展开由 toggleBook 驱动,已带"首次加载"判断)。
useEffect(() => {
for (const bookId of expandedBooks) {
void loadSessionList(bookId);
}
}, [bookDataVersion, expandedBooks, loadSessionList]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookDataVersion, loadSessionList]);
const toggleBook = (bookId: string) => {
setExpandedBooks((prev) => {
const next = new Set(prev);
if (next.has(bookId)) {
next.delete(bookId);
} else {
next.add(bookId);
return next;
}
next.add(bookId);
// 首次展开才拉:已有 sessionIdsByBook 数据就直接用缓存
if (sessionIdsByBook[bookId] === undefined) {
void loadSessionList(bookId);
}
return next;
@ -134,11 +139,12 @@ export function Sidebar({ nav, activePage, sse, t }: {
void loadSessionDetail(sessionId);
};
const handleCreateSession = async (bookId: string) => {
const handleCreateSession = (bookId: string) => {
// 前端创建草稿会话:对话区立即变空,但 session 文件不落盘;
// 发第一条消息时 sendMessage 会调 POST /sessions 真正创建。
setExpandedBooks((prev) => new Set(prev).add(bookId));
const sessionId = await createSession(bookId);
createDraftSession(bookId);
nav.toBook(bookId);
await loadSessionDetail(sessionId);
};
const handleRenameConfirm = async () => {
@ -221,7 +227,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
<div className="mt-0.5">
{bookSessions.map((session) => {
const isActiveSession = isActiveBook && activeSessionId === session.sessionId;
const label = getSessionLabel(session.sessionId, session.title);
const label = getSessionLabel(session);
return (
<div
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>
<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
type="button"
onClick={() => {
setRenameTarget(null);
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>
@ -428,7 +429,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
type="button"
onClick={() => void handleRenameConfirm()}
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>
@ -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 {