@steerable/agent-shell-web 0.6.47 → 0.6.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,18 +59,18 @@
59
59
  * │ ● 哲学家 │
60
60
  * │ + 用「教练」新建对话 │ ← inline CTA when sel ≠ current
61
61
  * ├──── divider ────────────────────┤
62
- * │ + 新对话 │ ← 只打开落地页,有内容才落库
62
+ * │ ✎ 新对话 │ ← 只打开落地页,有内容才落库
63
63
  * │ ⬡ 智能体管理 │ ← /settings?section=agents(独立页)
64
64
  * │ ⬡ Skill 设置 │ ← /settings?section=skills(独立页)
65
65
  * │ 🔌 MCP 设置 │ ← /settings?section=mcp(独立页)
66
66
  * │ 会话 v 📁+ │ ← 📁+ 打开新建项目弹窗
67
- * │ v 📁 项目A · 3 (hover: +··)│ ← + 新建对话;·· 菜单:重命名/换目录/访达/删
67
+ * │ v 📁 项目A (hover: ✎··)│ ← ✎ 新建对话;·· 菜单:重命名/换目录/访达/删
68
68
  * │ ...(项目内对话) │
69
69
  * │ 今天 │
70
70
  * │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
71
71
  * ├─────────────────────────────────┤
72
72
  * │ ▢_ 终端 ⌘T │
73
- * │ ⚙ 设置 │ ← /settings(模型 + 洞察/遥测/用量/搜索/安全)
73
+ * │ ⚙ 设置 v0.2.2 │ ← /settings;右侧是当前版本,检查更新在设置页
74
74
  * └─────────────────────────────────┘
75
75
  *
76
76
  * 项目模式:项目 = 名字 + 托管家目录(Documents/<应用名>/<项目名>/)+
@@ -82,14 +82,13 @@ import { createPortal } from "react-dom";
82
82
  import { useLocation, useNavigate, useParams } from "react-router-dom";
83
83
  import {
84
84
  LuChevronDown,
85
- LuChevronUp,
85
+ LuChevronRight,
86
86
  LuMessageSquare,
87
87
  LuCloudCog,
88
88
  LuTrash2,
89
89
  LuLoaderCircle,
90
- LuTerminal,
91
90
  LuSettings,
92
- LuPlus,
91
+ LuSquarePen,
93
92
  LuPanelLeftClose,
94
93
  LuBlocks,
95
94
  LuBot,
@@ -99,7 +98,9 @@ import {
99
98
  LuPencil,
100
99
  LuPlug,
101
100
  LuEllipsis,
101
+ LuCircleHelp,
102
102
  } from "react-icons/lu";
103
+ import { RiPushpin2Fill, RiPushpin2Line } from "react-icons/ri";
103
104
  import { parseChatTitle } from "@/lib/chat-title";
104
105
  import { getDateGroupLabel, getDateGroupPriority } from "@/lib/date-groups";
105
106
  import { getElectronBridge, isElectron } from "@/lib/electron-bridge";
@@ -109,41 +110,23 @@ import {
109
110
  deleteProject,
110
111
  listProjects,
111
112
  openLocalPath,
113
+ setChatPinned,
112
114
  updateProject,
113
115
  type LocalChat,
114
116
  type LocalChatAgent,
115
117
  type LocalProject,
116
118
  } from "@/lib/local-api";
119
+ import { usePendingAskUserChatIds } from "@/components/chat/AskUserPromptProvider";
120
+ import { usePendingApprovalChatIds } from "@/components/chat/ApprovalPromptProvider";
117
121
  import type { UseChatsAndAgentsResult } from "@/hooks/useChatsAndAgents";
118
122
  import type { RightPanelState } from "@/layouts/AgentLayout";
119
123
  import { BrandLockup } from "@/components/BrandLockup";
124
+ import {
125
+ SidebarVersionLabel,
126
+ useAppRelease,
127
+ } from "@/components/SidebarRelease";
120
128
  import { CreateProjectModal } from "@/components/CreateProjectModal";
121
129
 
122
- const DEFAULT_DOT_COLOR = "#7c3aed";
123
-
124
- function agentInitial(agent: LocalChatAgent | null): string {
125
- const name = (agent?.name || "").trim();
126
- return name ? name[0].toUpperCase() : "A";
127
- }
128
-
129
- function AgentDot({
130
- agent,
131
- size = 18,
132
- }: {
133
- agent: LocalChatAgent | null;
134
- size?: number;
135
- }) {
136
- const color = agent?.color || DEFAULT_DOT_COLOR;
137
- return (
138
- <span
139
- className="inline-flex flex-shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white shadow-sm"
140
- style={{ width: size, height: size, backgroundColor: color }}
141
- >
142
- {agentInitial(agent)}
143
- </span>
144
- );
145
- }
146
-
147
130
  function placeProjectMenu(anchor: HTMLElement): { top: number; left: number } {
148
131
  const box = anchor.getBoundingClientRect();
149
132
  const width = 224;
@@ -268,23 +251,24 @@ function ProjectOverflowMenu({
268
251
  interface AgentSidebarProps {
269
252
  data: UseChatsAndAgentsResult;
270
253
  /** 右侧栏当前打开的面板:null / 'terminal' / 包槽位 id(互斥)。 */
271
- rightPanel: RightPanelState;
254
+ rightPanel?: RightPanelState;
272
255
  /** 切换右侧栏面板显隐——面板不是路由也不是独立窗口,只是布局里的一栏。 */
273
- onToggleRightPanel: (kind: string) => void;
256
+ onToggleRightPanel?: (kind: string) => void;
274
257
  /** 收起侧边栏(AgentLayout 换成窄 rail,展开按钮在 rail 上)。 */
275
258
  onCollapse: () => void;
276
259
  }
277
260
 
278
261
  export function AgentSidebar({
279
262
  data,
280
- rightPanel,
281
- onToggleRightPanel,
263
+ rightPanel: _rightPanel,
264
+ onToggleRightPanel: _onToggleRightPanel,
282
265
  onCollapse,
283
266
  }: AgentSidebarProps) {
284
267
  const navigate = useNavigate();
285
268
  const location = useLocation();
286
269
  const { chatId: currentChatId } = useParams<{ chatId?: string }>();
287
270
  const bridge = getElectronBridge();
271
+ const release = useAppRelease();
288
272
  const onSettingsPage = location.pathname === "/settings";
289
273
  // /settings?section=skills|mcp|agents|(缺省 = 综合设置)—— 各自高亮。
290
274
  const settingsSection = useMemo(
@@ -300,9 +284,9 @@ export function AgentSidebar({
300
284
 
301
285
  const {
302
286
  chats,
303
- agents,
304
287
  isLoading: isChatLoading,
305
288
  error,
289
+ refreshChats,
306
290
  setSelectedAgentId,
307
291
  deleteChat,
308
292
  isLoadingMoreChats,
@@ -321,7 +305,43 @@ export function AgentSidebar({
321
305
  null,
322
306
  );
323
307
  const [deletingChatId, setDeletingChatId] = useState<string | null>(null);
324
- const [chatsExpanded, setChatsExpanded] = useState(true);
308
+ const [pinningChatId, setPinningChatId] = useState<string | null>(null);
309
+ const [streamingChatIds, setStreamingChatIds] = useState<Set<string>>(
310
+ () => new Set(),
311
+ );
312
+ const pendingAskUserChatIds = usePendingAskUserChatIds();
313
+ const pendingApprovalChatIds = usePendingApprovalChatIds();
314
+
315
+ useEffect(() => {
316
+ const handleStreamingChange = (event: Event) => {
317
+ const customEvent = event as CustomEvent<{
318
+ chatId: string;
319
+ isStreaming: boolean;
320
+ }>;
321
+ const { chatId, isStreaming } = customEvent.detail || {};
322
+ if (!chatId) return;
323
+ setStreamingChatIds((prev) => {
324
+ const next = new Set(prev);
325
+ if (isStreaming) {
326
+ next.add(chatId);
327
+ } else {
328
+ next.delete(chatId);
329
+ }
330
+ return next;
331
+ });
332
+ };
333
+ window.addEventListener("chat:streaming-change", handleStreamingChange);
334
+ return () => {
335
+ window.removeEventListener(
336
+ "chat:streaming-change",
337
+ handleStreamingChange,
338
+ );
339
+ };
340
+ }, []);
341
+
342
+ const [pinnedExpanded, setPinnedExpanded] = useState<boolean | null>(null);
343
+ const [projectsExpanded, setProjectsExpanded] = useState<boolean | null>(null);
344
+ const [recentsExpanded, setRecentsExpanded] = useState<boolean | null>(null);
325
345
  const chatScrollRef = useRef<HTMLDivElement>(null);
326
346
 
327
347
  // ───── 项目模式 ─────
@@ -537,6 +557,8 @@ export function AgentSidebar({
537
557
  sortDate,
538
558
  agentId: chat.agentId ?? null,
539
559
  projectId: chat.projectId ?? null,
560
+ isStreaming: chat.isStreaming,
561
+ needsUserInput: chat.needsUserInput,
540
562
  };
541
563
  })
542
564
  .sort((a, b) => {
@@ -574,7 +596,12 @@ export function AgentSidebar({
574
596
  [projects, normalizedChats, showProjectsChrome],
575
597
  );
576
598
 
577
- const chatGroups = useMemo(() => {
599
+ const pinnedChats = useMemo(
600
+ () => normalizedChats.filter((c) => c.isPinned),
601
+ [normalizedChats],
602
+ );
603
+
604
+ const recentChatGroups = useMemo(() => {
578
605
  const map = new Map<
579
606
  string,
580
607
  {
@@ -583,23 +610,26 @@ export function AgentSidebar({
583
610
  items: typeof normalizedChats;
584
611
  }
585
612
  >();
586
- // 置顶会话独立成组排在所有日期分组之前:pin-first 排序若只带进日期
587
- // 分组,「5 天前置顶的会话」会排在「今天」的普通会话之后,置顶语义
588
- // 就只剩组内有效——与用户点图钉时的预期不符。
589
- noProjectChats.forEach((chat) => {
590
- const label = chat.isPinned ? "置顶" : getDateGroupLabel(chat.sortDate);
591
- if (!map.has(label)) {
592
- map.set(label, {
593
- label,
594
- priority: chat.isPinned ? 0 : getDateGroupPriority(label),
595
- items: [],
596
- });
597
- }
598
- map.get(label)!.items.push(chat);
599
- });
613
+ noProjectChats
614
+ .filter((chat) => !chat.isPinned)
615
+ .forEach((chat) => {
616
+ const label = getDateGroupLabel(chat.sortDate);
617
+ if (!map.has(label)) {
618
+ map.set(label, {
619
+ label,
620
+ priority: getDateGroupPriority(label),
621
+ items: [],
622
+ });
623
+ }
624
+ map.get(label)!.items.push(chat);
625
+ });
600
626
  return Array.from(map.values()).sort((a, b) => a.priority - b.priority);
601
627
  }, [noProjectChats]);
602
628
 
629
+ const isPinnedExpanded = pinnedExpanded ?? pinnedChats.length > 0;
630
+ const isProjectsExpanded = projectsExpanded ?? true;
631
+ const isRecentsExpanded = recentsExpanded ?? true;
632
+
603
633
  const handleDeleteChat = useCallback(
604
634
  async (id: string) => {
605
635
  if (deletingChatId === id) return;
@@ -624,12 +654,37 @@ export function AgentSidebar({
624
654
  [confirmDeleteChatId, deleteChat, deletingChatId, currentChatId, navigate],
625
655
  );
626
656
 
657
+ const handleTogglePinChat = useCallback(
658
+ async (id: string, nextPinned: boolean) => {
659
+ if (pinningChatId === id) return;
660
+ setPinningChatId(id);
661
+ try {
662
+ await setChatPinned(id, nextPinned);
663
+ await refreshChats();
664
+ } catch (err) {
665
+ console.error("切换会话置顶失败:", err);
666
+ } finally {
667
+ setPinningChatId(null);
668
+ }
669
+ },
670
+ [pinningChatId, refreshChats],
671
+ );
672
+
627
673
  // 单条会话行——无项目日期分组和项目分组共用同一个渲染,避免两份 JSX。
628
674
  const renderChatRow = (chat: (typeof normalizedChats)[number]) => {
629
675
  const isCurrent = currentChatId === chat.id;
630
676
  const isConfirmingDelete = confirmDeleteChatId === chat.id;
631
677
  const isDeleting = deletingChatId === chat.id;
632
- const chatAgent = agents.find((a) => a.id === chat.agentId) ?? null;
678
+ const isPinning = pinningChatId === chat.id;
679
+ const isStreaming = Boolean(
680
+ chat.isStreaming || streamingChatIds.has(chat.id),
681
+ );
682
+ const needsUserInput = Boolean(
683
+ chat.needsUserInput ||
684
+ pendingAskUserChatIds.has(chat.id) ||
685
+ pendingApprovalChatIds.has(chat.id),
686
+ );
687
+
633
688
  return (
634
689
  <div
635
690
  key={chat.id}
@@ -653,61 +708,87 @@ export function AgentSidebar({
653
708
  chat.isAutomation ? `[由自动化触发] ${chat.title}` : chat.title
654
709
  }
655
710
  >
656
- {chat.isPinned && (
657
- <span className="shrink-0 text-[10px]" aria-label="已置顶">
658
- 📌
711
+ {needsUserInput ? (
712
+ <span
713
+ className="flex shrink-0 items-center justify-center text-amber-500 dark:text-amber-400"
714
+ title="有问题需要输入"
715
+ aria-label="等待用户输入"
716
+ >
717
+ <LuCircleHelp className="h-3.5 w-3.5 animate-pulse" />
659
718
  </span>
660
- )}
719
+ ) : isStreaming ? (
720
+ <span
721
+ className="flex shrink-0 items-center justify-center text-agent-foreground/70"
722
+ title="正在对话中"
723
+ aria-label="正在生成"
724
+ >
725
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
726
+ </span>
727
+ ) : null}
661
728
  {chat.isAutomation && (
662
729
  <LuCloudCog
663
730
  className="h-3 w-3 shrink-0 text-agent-muted-foreground"
664
731
  aria-label="由自动化触发"
665
732
  />
666
733
  )}
667
- <AgentDot agent={chatAgent} size={16} />
668
734
  <span className="min-w-0 truncate leading-none">{chat.title}</span>
669
735
  </button>
670
- {/* Gradient mask so the trash button doesn't paint
671
- over the chat title — fade matches the row's
672
- own background (canvas when selected, muted when
673
- hovered). */}
736
+ {/* Semi-transparent gradient container for action buttons:
737
+ fades long text smoothly to the left so buttons don't collide with text. */}
674
738
  <div
675
- aria-hidden="true"
676
- className={[
677
- "pointer-events-none absolute inset-y-0 right-0 w-12 rounded-r-full transition-opacity duration-200",
678
- isConfirmingDelete
679
- ? isCurrent
680
- ? "bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-100"
681
- : "bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-100"
682
- : isCurrent
683
- ? "bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-0 group-hover/item:opacity-100"
684
- : "bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-0 group-hover/item:opacity-100",
685
- ].join(" ")}
686
- />
687
- <button
688
- type="button"
689
- onClick={(event) => {
690
- event.stopPropagation();
691
- void handleDeleteChat(chat.id);
692
- }}
693
- disabled={isDeleting}
694
739
  className={[
695
- "absolute right-1 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full transition-all duration-200",
740
+ "absolute inset-y-0 right-0 flex items-center justify-end gap-0.5 pr-1 pl-8 rounded-r-full transition-all duration-200",
741
+ "bg-gradient-to-l from-agent-muted/95 via-agent-muted/80 to-transparent",
696
742
  isConfirmingDelete
697
- ? "bg-agent-destructive/10 text-agent-destructive opacity-100 hover:bg-agent-destructive/20"
698
- : "text-agent-muted-foreground opacity-0 hover:bg-agent-foreground/5 hover:text-agent-destructive group-hover/item:opacity-100 focus:opacity-100",
699
- "disabled:cursor-not-allowed disabled:opacity-100",
743
+ ? "opacity-100 pointer-events-auto"
744
+ : "opacity-0 group-hover/item:opacity-100 focus-within:opacity-100 pointer-events-none group-hover/item:pointer-events-auto focus-within:pointer-events-auto",
700
745
  ].join(" ")}
701
- title={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
702
- aria-label={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
703
- data-testid="sidebar-chat-delete"
704
746
  >
705
- <LuTrash2
706
- className={["h-3.5 w-3.5", isDeleting ? "animate-pulse" : ""].join(
707
- " ",
708
- )}
709
- />
710
- </button>
747
+ {!isConfirmingDelete && (
748
+ <button
749
+ type="button"
750
+ onClick={(event) => {
751
+ event.stopPropagation();
752
+ void handleTogglePinChat(chat.id, !chat.isPinned);
753
+ }}
754
+ disabled={isPinning}
755
+ className="flex h-6 w-6 items-center justify-center rounded-full text-agent-foreground/70 transition-all duration-200 hover:bg-agent-foreground/10 hover:text-agent-foreground disabled:cursor-not-allowed"
756
+ title={chat.isPinned ? "取消置顶" : "置顶会话"}
757
+ aria-label={chat.isPinned ? "取消置顶" : "置顶会话"}
758
+ data-testid="sidebar-chat-pin"
759
+ >
760
+ {chat.isPinned ? (
761
+ <RiPushpin2Fill className="h-3.5 w-3.5 text-agent-foreground" />
762
+ ) : (
763
+ <RiPushpin2Line className="h-3.5 w-3.5 text-agent-foreground/75 hover:text-agent-foreground" />
764
+ )}
765
+ </button>
766
+ )}
767
+ <button
768
+ type="button"
769
+ onClick={(event) => {
770
+ event.stopPropagation();
771
+ void handleDeleteChat(chat.id);
772
+ }}
773
+ disabled={isDeleting}
774
+ className={[
775
+ "flex h-6 w-6 items-center justify-center rounded-full transition-all duration-200",
776
+ isConfirmingDelete
777
+ ? "bg-agent-destructive/10 text-agent-destructive hover:bg-agent-destructive/20"
778
+ : "text-agent-foreground/70 hover:bg-agent-foreground/10 hover:text-agent-destructive",
779
+ "disabled:cursor-not-allowed disabled:opacity-100",
780
+ ].join(" ")}
781
+ title={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
782
+ aria-label={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
783
+ data-testid="sidebar-chat-delete"
784
+ >
785
+ <LuTrash2
786
+ className={["h-3.5 w-3.5", isDeleting ? "animate-pulse" : ""].join(
787
+ " ",
788
+ )}
789
+ />
790
+ </button>
791
+ </div>
711
792
  </div>
712
793
  );
713
794
  };
@@ -729,7 +810,7 @@ export function AgentSidebar({
729
810
  // Throttled by `isLoadingMoreChats` inside the hook.
730
811
  useEffect(() => {
731
812
  const container = chatScrollRef.current;
732
- if (!container || !chatsExpanded) return;
813
+ if (!container) return;
733
814
 
734
815
  const maybeLoadMore = () => {
735
816
  if (!hasMoreChats || isLoadingMoreChats) return;
@@ -748,26 +829,10 @@ export function AgentSidebar({
748
829
  hasMoreChats,
749
830
  isLoadingMoreChats,
750
831
  loadMoreChats,
751
- chatsExpanded,
832
+ isRecentsExpanded,
752
833
  normalizedChats.length,
753
834
  ]);
754
835
 
755
- // Cmd+T from the app menu — wired in src/main.ts:createMenu (sends
756
- // `menu:open-terminal`). Same pattern as `menu:new-chat` above. The
757
- // terminal is a toggleable panel beside the chat, so this just flips
758
- // the layout state owned by AgentLayout.
759
- const showTerminalChrome = hostToolChrome("terminal");
760
-
761
- useEffect(() => {
762
- if (!showTerminalChrome || !bridge?.onMenuOpenTerminal) return;
763
- bridge.onMenuOpenTerminal(() => {
764
- onToggleRightPanel("terminal");
765
- });
766
- return () => {
767
- bridge.offMenuOpenTerminal?.();
768
- };
769
- }, [bridge, onToggleRightPanel, showTerminalChrome]);
770
-
771
836
  const hasElectron = isElectron();
772
837
 
773
838
  return (
@@ -804,7 +869,7 @@ export function AgentSidebar({
804
869
  title="新建对话"
805
870
  data-testid="sidebar-new-chat"
806
871
  >
807
- <LuPlus className="h-3.5 w-3.5" />
872
+ <LuSquarePen className="h-3.5 w-3.5" />
808
873
  <span>新对话</span>
809
874
  </button>
810
875
  {settingsChrome("agents") && (
@@ -859,184 +924,257 @@ export function AgentSidebar({
859
924
  </button>
860
925
  )}
861
926
  </div>
862
- <div className="flex flex-shrink-0 items-center justify-between py-1 pl-2.5 pr-2.5">
863
- <button
864
- type="button"
865
- onClick={() => setChatsExpanded((v) => !v)}
866
- className="flex h-6 items-center rounded-full px-2.5 text-xs font-semibold tracking-wider text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
867
- >
868
- 会话
869
- <span className="ml-1">
870
- {chatsExpanded ? (
871
- <LuChevronUp className="h-3 w-3" />
872
- ) : (
873
- <LuChevronDown className="h-3 w-3" />
874
- )}
875
- </span>
876
- </button>
877
- <div className="flex items-center gap-1">
878
- {hasElectron && showProjectsChrome && (
879
- <button
880
- type="button"
881
- onClick={() => setCreateProjectOpen(true)}
882
- className="flex h-6 w-6 items-center justify-center rounded-full text-agent-muted-foreground transition-colors duration-200 hover:bg-agent-foreground/5 hover:text-agent-foreground"
883
- title="新建项目"
884
- aria-label="新建项目"
885
- >
886
- <LuFolderPlus className="h-3.5 w-3.5" />
887
- </button>
888
- )}
889
- </div>
890
- </div>
891
927
 
892
- {chatsExpanded && (
893
- <div
894
- ref={chatScrollRef}
895
- className="flex-1 overflow-y-auto px-2.5 pb-1"
896
- >
897
- {isChatLoading && chats.length === 0 && projects.length === 0 ? (
898
- <div className="flex items-center justify-center py-4 text-xs text-agent-muted-foreground">
899
- <LuLoaderCircle className="mr-1.5 h-3 w-3 animate-spin" />
900
- 加载中...
901
- </div>
902
- ) : chatGroups.length === 0 && projectGroups.length === 0 ? (
903
- <div className="flex flex-col items-center gap-1.5 py-4 text-xs text-agent-muted-foreground">
904
- <LuMessageSquare className="h-4 w-4 text-agent-muted-foreground/60" />
905
- 暂无会话
928
+ <div
929
+ ref={chatScrollRef}
930
+ className="flex-1 overflow-y-auto px-2.5 pb-1"
931
+ >
932
+ {isChatLoading && chats.length === 0 && projects.length === 0 ? (
933
+ <div className="flex items-center justify-center py-4 text-xs text-agent-muted-foreground">
934
+ <LuLoaderCircle className="mr-1.5 h-3 w-3 animate-spin" />
935
+ 加载中...
936
+ </div>
937
+ ) : (
938
+ <>
939
+ {/* 1. 置顶 (Pinned):没有置顶会话时整组不出现 */}
940
+ {pinnedChats.length > 0 && (
941
+ <div className="mb-1.5">
942
+ <div className="group/section flex h-7 items-center justify-between rounded-agent-md px-1.5 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground">
943
+ <button
944
+ type="button"
945
+ onClick={() => setPinnedExpanded(!isPinnedExpanded)}
946
+ className="flex flex-1 min-w-0 items-center gap-1 text-left font-medium text-agent-muted-foreground hover:text-agent-foreground"
947
+ >
948
+ <span>置顶</span>
949
+ {isPinnedExpanded ? (
950
+ <LuChevronDown className="h-3 w-3 shrink-0 opacity-70" />
951
+ ) : (
952
+ <LuChevronRight className="h-3 w-3 shrink-0 opacity-70" />
953
+ )}
954
+ </button>
955
+ </div>
956
+ {isPinnedExpanded && (
957
+ <div className="space-y-0.5">
958
+ {pinnedChats.map((chat) => renderChatRow(chat))}
959
+ </div>
960
+ )}
906
961
  </div>
907
- ) : (
908
- <>
909
- {/* 项目分组在前:组头可折叠,hover 出 + 新建对话 / ·· 菜单 */}
910
- {showProjectsChrome &&
911
- projectGroups.map(({ project, items }) => (
912
- <div key={project.id} className="mb-1">
913
- <div className="group/proj relative">
914
- {renamingProjectId === project.id ? (
915
- <form
916
- className="flex items-center px-2.5 pb-1 pt-1.5"
917
- onSubmit={(event) => {
918
- event.preventDefault();
919
- void handleRenameProject(project.id);
920
- }}
921
- >
922
- <input
923
- autoFocus
924
- value={renamingValue}
925
- onChange={(event) =>
926
- setRenamingValue(event.target.value)
927
- }
928
- onBlur={() => void handleRenameProject(project.id)}
929
- onKeyDown={(event) => {
930
- if (event.key === "Escape")
931
- setRenamingProjectId(null);
932
- }}
933
- className="h-6 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
934
- />
935
- </form>
962
+ )}
963
+
964
+ {/* 2. 项目 (Projects) */}
965
+ {showProjectsChrome && (
966
+ <div className="mb-1.5">
967
+ <div className="group/section flex h-7 items-center justify-between rounded-agent-md px-1.5 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground">
968
+ <button
969
+ type="button"
970
+ onClick={() => setProjectsExpanded(!isProjectsExpanded)}
971
+ className="flex flex-1 min-w-0 items-center gap-1 text-left font-medium text-agent-muted-foreground hover:text-agent-foreground"
972
+ >
973
+ <span>项目</span>
974
+ {isProjectsExpanded ? (
975
+ <LuChevronDown className="h-3 w-3 shrink-0 opacity-70" />
976
+ ) : (
977
+ <LuChevronRight className="h-3 w-3 shrink-0 opacity-70" />
978
+ )}
979
+ </button>
980
+ {hasElectron && (
981
+ <button
982
+ type="button"
983
+ onClick={(e) => {
984
+ e.stopPropagation();
985
+ setCreateProjectOpen(true);
986
+ }}
987
+ className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground opacity-0 transition-opacity duration-200 hover:bg-agent-foreground/10 hover:text-agent-foreground group-hover/section:opacity-100 focus:opacity-100"
988
+ title="新建项目"
989
+ aria-label="新建项目"
990
+ >
991
+ <LuFolderPlus className="h-3.5 w-3.5" />
992
+ </button>
993
+ )}
994
+ </div>
995
+ {isProjectsExpanded && (
996
+ <div className="space-y-0.5">
997
+ {projectGroups.length === 0 ? (
998
+ <div className="px-2.5 py-1 text-[11px] text-agent-muted-foreground/60">
999
+ 暂无项目
1000
+ </div>
936
1001
  ) : (
937
- <>
938
- <button
939
- type="button"
940
- onClick={() => toggleProjectCollapsed(project.id)}
941
- className="flex w-full min-w-0 items-center px-2.5 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-wider text-agent-muted-foreground/80 transition-colors hover:text-agent-foreground"
942
- title={`${project.name}\n${project.folderPath}`}
943
- >
944
- <LuFolder className="mr-1 h-3 w-3 shrink-0" />
945
- <span className="min-w-0 truncate normal-case">
946
- {project.name}
947
- </span>
948
- <span className="ml-0.5 shrink-0">
949
- {collapsedProjectIds.has(project.id) ? (
950
- <LuChevronDown className="h-3 w-3" />
1002
+ projectGroups.map(({ project, items }) => (
1003
+ <div key={project.id} className="mb-1">
1004
+ <div className="group/proj relative">
1005
+ {renamingProjectId === project.id ? (
1006
+ <form
1007
+ className="flex items-center px-2.5 pb-1 pt-1.5"
1008
+ onSubmit={(event) => {
1009
+ event.preventDefault();
1010
+ void handleRenameProject(project.id);
1011
+ }}
1012
+ >
1013
+ <input
1014
+ autoFocus
1015
+ value={renamingValue}
1016
+ onChange={(event) =>
1017
+ setRenamingValue(event.target.value)
1018
+ }
1019
+ onBlur={() => void handleRenameProject(project.id)}
1020
+ onKeyDown={(event) => {
1021
+ if (event.key === "Escape")
1022
+ setRenamingProjectId(null);
1023
+ }}
1024
+ className="h-6 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-2 text-xs text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
1025
+ />
1026
+ </form>
951
1027
  ) : (
952
- <LuChevronUp className="h-3 w-3" />
1028
+ <>
1029
+ <button
1030
+ type="button"
1031
+ onClick={() => toggleProjectCollapsed(project.id)}
1032
+ className="flex w-full min-w-0 items-center px-2 pb-0.5 pt-1 text-[11px] font-medium text-agent-muted-foreground/90 transition-colors hover:text-agent-foreground"
1033
+ title={`${project.name}\n${project.folderPath}`}
1034
+ >
1035
+ <LuFolder className="mr-1 h-3.5 w-3.5 shrink-0" />
1036
+ <span className="min-w-0 truncate normal-case">
1037
+ {project.name}
1038
+ </span>
1039
+ <span className="ml-1 shrink-0">
1040
+ {collapsedProjectIds.has(project.id) ? (
1041
+ <LuChevronRight className="h-3 w-3" />
1042
+ ) : (
1043
+ <LuChevronDown className="h-3 w-3" />
1044
+ )}
1045
+ </span>
1046
+ </button>
1047
+ <div
1048
+ className={`absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 transition-opacity ${
1049
+ projectMenu?.id === project.id
1050
+ ? "opacity-100"
1051
+ : "opacity-0 group-hover/proj:opacity-100"
1052
+ }`}
1053
+ >
1054
+ <button
1055
+ type="button"
1056
+ onClick={() => handleOpenNewChat(project.id)}
1057
+ className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
1058
+ title="在此项目下新建对话"
1059
+ >
1060
+ <LuSquarePen className="h-3 w-3" />
1061
+ </button>
1062
+ <button
1063
+ type="button"
1064
+ aria-label="项目菜单"
1065
+ aria-expanded={projectMenu?.id === project.id}
1066
+ onClick={(event) => {
1067
+ const button = event.currentTarget;
1068
+ setConfirmDeleteProjectId(null);
1069
+ setProjectMenu((current) =>
1070
+ current?.id === project.id
1071
+ ? null
1072
+ : { id: project.id, anchor: button },
1073
+ );
1074
+ }}
1075
+ className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
1076
+ title="项目菜单"
1077
+ >
1078
+ <LuEllipsis className="h-3 w-3" />
1079
+ </button>
1080
+ </div>
1081
+ </>
953
1082
  )}
954
- </span>
955
- {items.length > 0 && (
956
- <span className="ml-1 shrink-0 font-normal">
957
- · {items.length}
958
- </span>
1083
+ </div>
1084
+ {!collapsedProjectIds.has(project.id) && (
1085
+ <div className="space-y-0.5 pl-2">
1086
+ {items.length === 0 ? (
1087
+ <div className="px-2.5 py-0.5 text-[11px] text-agent-muted-foreground/60">
1088
+ 暂无会话 — hover 项目名点 + 新建
1089
+ </div>
1090
+ ) : (
1091
+ items.map((chat) => renderChatRow(chat))
1092
+ )}
1093
+ </div>
959
1094
  )}
960
- </button>
961
- <div
962
- className={`absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 transition-opacity ${
963
- projectMenu?.id === project.id
964
- ? "opacity-100"
965
- : "opacity-0 group-hover/proj:opacity-100"
966
- }`}
967
- >
968
- <button
969
- type="button"
970
- onClick={() => handleOpenNewChat(project.id)}
971
- className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
972
- title="在此项目下新建对话"
973
- >
974
- <LuPlus className="h-3 w-3" />
975
- </button>
976
- <button
977
- type="button"
978
- aria-label="项目菜单"
979
- aria-expanded={projectMenu?.id === project.id}
980
- onClick={(event) => {
981
- const button = event.currentTarget;
982
- setConfirmDeleteProjectId(null);
983
- setProjectMenu((current) =>
984
- current?.id === project.id
985
- ? null
986
- : { id: project.id, anchor: button },
987
- );
988
- }}
989
- className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
990
- title="项目菜单"
991
- >
992
- <LuEllipsis className="h-3 w-3" />
993
- </button>
994
1095
  </div>
995
- </>
1096
+ ))
996
1097
  )}
997
1098
  </div>
998
- {!collapsedProjectIds.has(project.id) && (
999
- <div className="space-y-0.5">
1000
- {items.length === 0 ? (
1001
- <div className="px-2.5 py-0.5 text-[11px] text-agent-muted-foreground/60">
1002
- 暂无会话 — hover 项目名点 + 新建
1003
- </div>
1004
- ) : (
1005
- items.map((chat) => renderChatRow(chat))
1006
- )}
1007
- </div>
1099
+ )}
1100
+ </div>
1101
+ )}
1102
+
1103
+ {/* 3. 最近 (Recents) */}
1104
+ <div className="mb-1.5">
1105
+ <div className="group/section flex h-7 items-center justify-between rounded-agent-md px-1.5 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground">
1106
+ <button
1107
+ type="button"
1108
+ onClick={() => setRecentsExpanded(!isRecentsExpanded)}
1109
+ className="flex flex-1 min-w-0 items-center gap-1 text-left font-medium text-agent-muted-foreground hover:text-agent-foreground"
1110
+ >
1111
+ <span>最近</span>
1112
+ {isRecentsExpanded ? (
1113
+ <LuChevronDown className="h-3 w-3 shrink-0 opacity-70" />
1114
+ ) : (
1115
+ <LuChevronRight className="h-3 w-3 shrink-0 opacity-70" />
1008
1116
  )}
1117
+ </button>
1118
+ <div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/section:opacity-100 focus-within:opacity-100">
1119
+ <button
1120
+ type="button"
1121
+ onClick={(e) => {
1122
+ e.stopPropagation();
1123
+ handleOpenNewChat();
1124
+ }}
1125
+ className="flex h-5 w-5 items-center justify-center rounded-full text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
1126
+ title="新对话"
1127
+ aria-label="新对话"
1128
+ >
1129
+ <LuSquarePen className="h-3 w-3" />
1130
+ </button>
1009
1131
  </div>
1010
- ))}
1011
- {/* 无项目对话排在项目分组之后:保持原有日期分组 */}
1012
- {chatGroups.map((group) => (
1013
- <div key={group.label} className="mb-1">
1014
- <div className="px-2.5 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-wider text-agent-muted-foreground/80">
1015
- {group.label}
1016
- </div>
1017
- <div className="space-y-0.5">
1018
- {group.items.map((chat) => renderChatRow(chat))}
1019
- </div>
1132
+ </div>
1133
+ {isRecentsExpanded && (
1134
+ <div className="space-y-0.5">
1135
+ {recentChatGroups.length === 0 ? (
1136
+ chats.length === 0 && projects.length === 0 ? (
1137
+ <div className="flex flex-col items-center gap-1.5 py-4 text-xs text-agent-muted-foreground">
1138
+ <LuMessageSquare className="h-4 w-4 text-agent-muted-foreground/60" />
1139
+ 暂无会话
1140
+ </div>
1141
+ ) : (
1142
+ <div className="px-2.5 py-1 text-[11px] text-agent-muted-foreground/60">
1143
+ 暂无会话
1144
+ </div>
1145
+ )
1146
+ ) : (
1147
+ recentChatGroups.map((group) => (
1148
+ <div key={group.label} className="mb-1">
1149
+ <div className="px-2 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-wider text-agent-muted-foreground/80">
1150
+ {group.label}
1151
+ </div>
1152
+ <div className="space-y-0.5">
1153
+ {group.items.map((chat) => renderChatRow(chat))}
1154
+ </div>
1155
+ </div>
1156
+ ))
1157
+ )}
1020
1158
  </div>
1021
- ))}
1022
- </>
1023
- )}
1024
- {(chatGroups.length > 0 || projectGroups.length > 0) && (
1025
- <div className="px-2 py-2 text-center text-[11px] text-agent-muted-foreground">
1026
- {isLoadingMoreChats ? (
1027
- <span className="inline-flex items-center gap-1">
1028
- <LuLoaderCircle className="h-3 w-3 animate-spin" />
1029
- 加载更多...
1030
- </span>
1031
- ) : hasMoreChats ? (
1032
- "继续下滑加载更多"
1033
- ) : (
1034
- `共 ${normalizedChats.length} 个会话`
1035
1159
  )}
1036
1160
  </div>
1037
- )}
1038
- </div>
1039
- )}
1161
+ </>
1162
+ )}
1163
+ {normalizedChats.length > 0 && (
1164
+ <div className="px-2 py-2 text-center text-[11px] text-agent-muted-foreground">
1165
+ {isLoadingMoreChats ? (
1166
+ <span className="inline-flex items-center gap-1">
1167
+ <LuLoaderCircle className="h-3 w-3 animate-spin" />
1168
+ 加载更多...
1169
+ </span>
1170
+ ) : hasMoreChats ? (
1171
+ "继续下滑加载更多"
1172
+ ) : (
1173
+ `共 ${normalizedChats.length} 个会话`
1174
+ )}
1175
+ </div>
1176
+ )}
1177
+ </div>
1040
1178
  </div>
1041
1179
 
1042
1180
  {(error || projectError) && (
@@ -1048,34 +1186,14 @@ export function AgentSidebar({
1048
1186
  </div>
1049
1187
  )}
1050
1188
 
1051
- {/* ───── Footer: 右侧面板切换(终端 | 包槽位)+ 设置 ───── */}
1189
+ {/* ───── Footer: 设置 ───── */}
1052
1190
  <div className="flex-shrink-0 border-t border-agent-border/40 px-2.5 py-1.5">
1053
- {showTerminalChrome ? (
1054
- <button
1055
- type="button"
1056
- onClick={() => onToggleRightPanel("terminal")}
1057
- className={[
1058
- "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
1059
- rightPanel === "terminal"
1060
- ? "bg-agent-foreground/10 font-medium text-agent-foreground"
1061
- : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground",
1062
- ].join(" ")}
1063
- title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板 (${isMac ? "⌘T" : "Ctrl+T"})`}
1064
- data-testid="sidebar-terminal"
1065
- >
1066
- <LuTerminal className="h-3.5 w-3.5" />
1067
- <span>终端</span>
1068
- <span className="ml-auto text-[10px] text-agent-muted-foreground/70">
1069
- {isMac ? "⌘T" : "Ctrl+T"}
1070
- </span>
1071
- </button>
1072
- ) : null}
1073
1191
  {hasGeneralSettingsChrome() && (
1074
1192
  <button
1075
1193
  type="button"
1076
1194
  onClick={() => navigate("/settings")}
1077
1195
  className={[
1078
- "mt-0.5 flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
1196
+ "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
1079
1197
  onGeneralSettings
1080
1198
  ? "bg-agent-foreground/10 font-medium text-agent-foreground"
1081
1199
  : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",
@@ -1085,8 +1203,14 @@ export function AgentSidebar({
1085
1203
  >
1086
1204
  <LuSettings className="h-3.5 w-3.5" />
1087
1205
  <span>设置</span>
1206
+ <SidebarVersionLabel release={release} />
1088
1207
  </button>
1089
1208
  )}
1209
+ {!hasGeneralSettingsChrome() && release.version ? (
1210
+ <div className="mt-0.5 flex h-7 w-full items-center px-2.5">
1211
+ <SidebarVersionLabel release={release} />
1212
+ </div>
1213
+ ) : null}
1090
1214
  </div>
1091
1215
 
1092
1216
  {projectMenu &&