@steerable/agent-shell-web 0.6.48 → 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,12 +59,12 @@
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
  * │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
@@ -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,11 +110,14 @@ 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";
@@ -123,31 +127,6 @@ import {
123
127
  } from "@/components/SidebarRelease";
124
128
  import { CreateProjectModal } from "@/components/CreateProjectModal";
125
129
 
126
- const DEFAULT_DOT_COLOR = "#7c3aed";
127
-
128
- function agentInitial(agent: LocalChatAgent | null): string {
129
- const name = (agent?.name || "").trim();
130
- return name ? name[0].toUpperCase() : "A";
131
- }
132
-
133
- function AgentDot({
134
- agent,
135
- size = 18,
136
- }: {
137
- agent: LocalChatAgent | null;
138
- size?: number;
139
- }) {
140
- const color = agent?.color || DEFAULT_DOT_COLOR;
141
- return (
142
- <span
143
- className="inline-flex flex-shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white shadow-sm"
144
- style={{ width: size, height: size, backgroundColor: color }}
145
- >
146
- {agentInitial(agent)}
147
- </span>
148
- );
149
- }
150
-
151
130
  function placeProjectMenu(anchor: HTMLElement): { top: number; left: number } {
152
131
  const box = anchor.getBoundingClientRect();
153
132
  const width = 224;
@@ -272,17 +251,17 @@ function ProjectOverflowMenu({
272
251
  interface AgentSidebarProps {
273
252
  data: UseChatsAndAgentsResult;
274
253
  /** 右侧栏当前打开的面板:null / 'terminal' / 包槽位 id(互斥)。 */
275
- rightPanel: RightPanelState;
254
+ rightPanel?: RightPanelState;
276
255
  /** 切换右侧栏面板显隐——面板不是路由也不是独立窗口,只是布局里的一栏。 */
277
- onToggleRightPanel: (kind: string) => void;
256
+ onToggleRightPanel?: (kind: string) => void;
278
257
  /** 收起侧边栏(AgentLayout 换成窄 rail,展开按钮在 rail 上)。 */
279
258
  onCollapse: () => void;
280
259
  }
281
260
 
282
261
  export function AgentSidebar({
283
262
  data,
284
- rightPanel,
285
- onToggleRightPanel,
263
+ rightPanel: _rightPanel,
264
+ onToggleRightPanel: _onToggleRightPanel,
286
265
  onCollapse,
287
266
  }: AgentSidebarProps) {
288
267
  const navigate = useNavigate();
@@ -305,9 +284,9 @@ export function AgentSidebar({
305
284
 
306
285
  const {
307
286
  chats,
308
- agents,
309
287
  isLoading: isChatLoading,
310
288
  error,
289
+ refreshChats,
311
290
  setSelectedAgentId,
312
291
  deleteChat,
313
292
  isLoadingMoreChats,
@@ -326,7 +305,43 @@ export function AgentSidebar({
326
305
  null,
327
306
  );
328
307
  const [deletingChatId, setDeletingChatId] = useState<string | null>(null);
329
- 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);
330
345
  const chatScrollRef = useRef<HTMLDivElement>(null);
331
346
 
332
347
  // ───── 项目模式 ─────
@@ -542,6 +557,8 @@ export function AgentSidebar({
542
557
  sortDate,
543
558
  agentId: chat.agentId ?? null,
544
559
  projectId: chat.projectId ?? null,
560
+ isStreaming: chat.isStreaming,
561
+ needsUserInput: chat.needsUserInput,
545
562
  };
546
563
  })
547
564
  .sort((a, b) => {
@@ -579,7 +596,12 @@ export function AgentSidebar({
579
596
  [projects, normalizedChats, showProjectsChrome],
580
597
  );
581
598
 
582
- const chatGroups = useMemo(() => {
599
+ const pinnedChats = useMemo(
600
+ () => normalizedChats.filter((c) => c.isPinned),
601
+ [normalizedChats],
602
+ );
603
+
604
+ const recentChatGroups = useMemo(() => {
583
605
  const map = new Map<
584
606
  string,
585
607
  {
@@ -588,23 +610,26 @@ export function AgentSidebar({
588
610
  items: typeof normalizedChats;
589
611
  }
590
612
  >();
591
- // 置顶会话独立成组排在所有日期分组之前:pin-first 排序若只带进日期
592
- // 分组,「5 天前置顶的会话」会排在「今天」的普通会话之后,置顶语义
593
- // 就只剩组内有效——与用户点图钉时的预期不符。
594
- noProjectChats.forEach((chat) => {
595
- const label = chat.isPinned ? "置顶" : getDateGroupLabel(chat.sortDate);
596
- if (!map.has(label)) {
597
- map.set(label, {
598
- label,
599
- priority: chat.isPinned ? 0 : getDateGroupPriority(label),
600
- items: [],
601
- });
602
- }
603
- map.get(label)!.items.push(chat);
604
- });
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
+ });
605
626
  return Array.from(map.values()).sort((a, b) => a.priority - b.priority);
606
627
  }, [noProjectChats]);
607
628
 
629
+ const isPinnedExpanded = pinnedExpanded ?? pinnedChats.length > 0;
630
+ const isProjectsExpanded = projectsExpanded ?? true;
631
+ const isRecentsExpanded = recentsExpanded ?? true;
632
+
608
633
  const handleDeleteChat = useCallback(
609
634
  async (id: string) => {
610
635
  if (deletingChatId === id) return;
@@ -629,12 +654,37 @@ export function AgentSidebar({
629
654
  [confirmDeleteChatId, deleteChat, deletingChatId, currentChatId, navigate],
630
655
  );
631
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
+
632
673
  // 单条会话行——无项目日期分组和项目分组共用同一个渲染,避免两份 JSX。
633
674
  const renderChatRow = (chat: (typeof normalizedChats)[number]) => {
634
675
  const isCurrent = currentChatId === chat.id;
635
676
  const isConfirmingDelete = confirmDeleteChatId === chat.id;
636
677
  const isDeleting = deletingChatId === chat.id;
637
- 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
+
638
688
  return (
639
689
  <div
640
690
  key={chat.id}
@@ -658,61 +708,87 @@ export function AgentSidebar({
658
708
  chat.isAutomation ? `[由自动化触发] ${chat.title}` : chat.title
659
709
  }
660
710
  >
661
- {chat.isPinned && (
662
- <span className="shrink-0 text-[10px]" aria-label="已置顶">
663
- 📌
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" />
664
718
  </span>
665
- )}
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}
666
728
  {chat.isAutomation && (
667
729
  <LuCloudCog
668
730
  className="h-3 w-3 shrink-0 text-agent-muted-foreground"
669
731
  aria-label="由自动化触发"
670
732
  />
671
733
  )}
672
- <AgentDot agent={chatAgent} size={16} />
673
734
  <span className="min-w-0 truncate leading-none">{chat.title}</span>
674
735
  </button>
675
- {/* Gradient mask so the trash button doesn't paint
676
- over the chat title — fade matches the row's
677
- own background (canvas when selected, muted when
678
- hovered). */}
736
+ {/* Semi-transparent gradient container for action buttons:
737
+ fades long text smoothly to the left so buttons don't collide with text. */}
679
738
  <div
680
- aria-hidden="true"
681
739
  className={[
682
- "pointer-events-none absolute inset-y-0 right-0 w-12 rounded-r-full transition-opacity 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",
683
742
  isConfirmingDelete
684
- ? isCurrent
685
- ? "bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-100"
686
- : "bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-100"
687
- : isCurrent
688
- ? "bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-0 group-hover/item:opacity-100"
689
- : "bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-0 group-hover/item: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",
690
745
  ].join(" ")}
691
- />
692
- <button
693
- type="button"
694
- onClick={(event) => {
695
- event.stopPropagation();
696
- void handleDeleteChat(chat.id);
697
- }}
698
- disabled={isDeleting}
699
- className={[
700
- "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",
701
- isConfirmingDelete
702
- ? "bg-agent-destructive/10 text-agent-destructive opacity-100 hover:bg-agent-destructive/20"
703
- : "text-agent-muted-foreground opacity-0 hover:bg-agent-foreground/5 hover:text-agent-destructive group-hover/item:opacity-100 focus:opacity-100",
704
- "disabled:cursor-not-allowed disabled:opacity-100",
705
- ].join(" ")}
706
- title={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
707
- aria-label={isConfirmingDelete ? "再次点击确认删除" : "删除会话"}
708
- data-testid="sidebar-chat-delete"
709
746
  >
710
- <LuTrash2
711
- className={["h-3.5 w-3.5", isDeleting ? "animate-pulse" : ""].join(
712
- " ",
713
- )}
714
- />
715
- </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>
716
792
  </div>
717
793
  );
718
794
  };
@@ -734,7 +810,7 @@ export function AgentSidebar({
734
810
  // Throttled by `isLoadingMoreChats` inside the hook.
735
811
  useEffect(() => {
736
812
  const container = chatScrollRef.current;
737
- if (!container || !chatsExpanded) return;
813
+ if (!container) return;
738
814
 
739
815
  const maybeLoadMore = () => {
740
816
  if (!hasMoreChats || isLoadingMoreChats) return;
@@ -753,26 +829,10 @@ export function AgentSidebar({
753
829
  hasMoreChats,
754
830
  isLoadingMoreChats,
755
831
  loadMoreChats,
756
- chatsExpanded,
832
+ isRecentsExpanded,
757
833
  normalizedChats.length,
758
834
  ]);
759
835
 
760
- // Cmd+T from the app menu — wired in src/main.ts:createMenu (sends
761
- // `menu:open-terminal`). Same pattern as `menu:new-chat` above. The
762
- // terminal is a toggleable panel beside the chat, so this just flips
763
- // the layout state owned by AgentLayout.
764
- const showTerminalChrome = hostToolChrome("terminal");
765
-
766
- useEffect(() => {
767
- if (!showTerminalChrome || !bridge?.onMenuOpenTerminal) return;
768
- bridge.onMenuOpenTerminal(() => {
769
- onToggleRightPanel("terminal");
770
- });
771
- return () => {
772
- bridge.offMenuOpenTerminal?.();
773
- };
774
- }, [bridge, onToggleRightPanel, showTerminalChrome]);
775
-
776
836
  const hasElectron = isElectron();
777
837
 
778
838
  return (
@@ -809,7 +869,7 @@ export function AgentSidebar({
809
869
  title="新建对话"
810
870
  data-testid="sidebar-new-chat"
811
871
  >
812
- <LuPlus className="h-3.5 w-3.5" />
872
+ <LuSquarePen className="h-3.5 w-3.5" />
813
873
  <span>新对话</span>
814
874
  </button>
815
875
  {settingsChrome("agents") && (
@@ -864,184 +924,257 @@ export function AgentSidebar({
864
924
  </button>
865
925
  )}
866
926
  </div>
867
- <div className="flex flex-shrink-0 items-center justify-between py-1 pl-2.5 pr-2.5">
868
- <button
869
- type="button"
870
- onClick={() => setChatsExpanded((v) => !v)}
871
- 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"
872
- >
873
- 会话
874
- <span className="ml-1">
875
- {chatsExpanded ? (
876
- <LuChevronUp className="h-3 w-3" />
877
- ) : (
878
- <LuChevronDown className="h-3 w-3" />
879
- )}
880
- </span>
881
- </button>
882
- <div className="flex items-center gap-1">
883
- {hasElectron && showProjectsChrome && (
884
- <button
885
- type="button"
886
- onClick={() => setCreateProjectOpen(true)}
887
- 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"
888
- title="新建项目"
889
- aria-label="新建项目"
890
- >
891
- <LuFolderPlus className="h-3.5 w-3.5" />
892
- </button>
893
- )}
894
- </div>
895
- </div>
896
927
 
897
- {chatsExpanded && (
898
- <div
899
- ref={chatScrollRef}
900
- className="flex-1 overflow-y-auto px-2.5 pb-1"
901
- >
902
- {isChatLoading && chats.length === 0 && projects.length === 0 ? (
903
- <div className="flex items-center justify-center py-4 text-xs text-agent-muted-foreground">
904
- <LuLoaderCircle className="mr-1.5 h-3 w-3 animate-spin" />
905
- 加载中...
906
- </div>
907
- ) : chatGroups.length === 0 && projectGroups.length === 0 ? (
908
- <div className="flex flex-col items-center gap-1.5 py-4 text-xs text-agent-muted-foreground">
909
- <LuMessageSquare className="h-4 w-4 text-agent-muted-foreground/60" />
910
- 暂无会话
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
+ )}
911
961
  </div>
912
- ) : (
913
- <>
914
- {/* 项目分组在前:组头可折叠,hover 出 + 新建对话 / ·· 菜单 */}
915
- {showProjectsChrome &&
916
- projectGroups.map(({ project, items }) => (
917
- <div key={project.id} className="mb-1">
918
- <div className="group/proj relative">
919
- {renamingProjectId === project.id ? (
920
- <form
921
- className="flex items-center px-2.5 pb-1 pt-1.5"
922
- onSubmit={(event) => {
923
- event.preventDefault();
924
- void handleRenameProject(project.id);
925
- }}
926
- >
927
- <input
928
- autoFocus
929
- value={renamingValue}
930
- onChange={(event) =>
931
- setRenamingValue(event.target.value)
932
- }
933
- onBlur={() => void handleRenameProject(project.id)}
934
- onKeyDown={(event) => {
935
- if (event.key === "Escape")
936
- setRenamingProjectId(null);
937
- }}
938
- 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"
939
- />
940
- </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>
941
1001
  ) : (
942
- <>
943
- <button
944
- type="button"
945
- onClick={() => toggleProjectCollapsed(project.id)}
946
- 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"
947
- title={`${project.name}\n${project.folderPath}`}
948
- >
949
- <LuFolder className="mr-1 h-3 w-3 shrink-0" />
950
- <span className="min-w-0 truncate normal-case">
951
- {project.name}
952
- </span>
953
- <span className="ml-0.5 shrink-0">
954
- {collapsedProjectIds.has(project.id) ? (
955
- <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>
956
1027
  ) : (
957
- <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
+ </>
958
1082
  )}
959
- </span>
960
- {items.length > 0 && (
961
- <span className="ml-1 shrink-0 font-normal">
962
- · {items.length}
963
- </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>
964
1094
  )}
965
- </button>
966
- <div
967
- className={`absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 transition-opacity ${
968
- projectMenu?.id === project.id
969
- ? "opacity-100"
970
- : "opacity-0 group-hover/proj:opacity-100"
971
- }`}
972
- >
973
- <button
974
- type="button"
975
- onClick={() => handleOpenNewChat(project.id)}
976
- 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"
977
- title="在此项目下新建对话"
978
- >
979
- <LuPlus className="h-3 w-3" />
980
- </button>
981
- <button
982
- type="button"
983
- aria-label="项目菜单"
984
- aria-expanded={projectMenu?.id === project.id}
985
- onClick={(event) => {
986
- const button = event.currentTarget;
987
- setConfirmDeleteProjectId(null);
988
- setProjectMenu((current) =>
989
- current?.id === project.id
990
- ? null
991
- : { id: project.id, anchor: button },
992
- );
993
- }}
994
- 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"
995
- title="项目菜单"
996
- >
997
- <LuEllipsis className="h-3 w-3" />
998
- </button>
999
1095
  </div>
1000
- </>
1096
+ ))
1001
1097
  )}
1002
1098
  </div>
1003
- {!collapsedProjectIds.has(project.id) && (
1004
- <div className="space-y-0.5">
1005
- {items.length === 0 ? (
1006
- <div className="px-2.5 py-0.5 text-[11px] text-agent-muted-foreground/60">
1007
- 暂无会话 — hover 项目名点 + 新建
1008
- </div>
1009
- ) : (
1010
- items.map((chat) => renderChatRow(chat))
1011
- )}
1012
- </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" />
1013
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>
1014
1131
  </div>
1015
- ))}
1016
- {/* 无项目对话排在项目分组之后:保持原有日期分组 */}
1017
- {chatGroups.map((group) => (
1018
- <div key={group.label} className="mb-1">
1019
- <div className="px-2.5 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-wider text-agent-muted-foreground/80">
1020
- {group.label}
1021
- </div>
1022
- <div className="space-y-0.5">
1023
- {group.items.map((chat) => renderChatRow(chat))}
1024
- </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
+ )}
1025
1158
  </div>
1026
- ))}
1027
- </>
1028
- )}
1029
- {(chatGroups.length > 0 || projectGroups.length > 0) && (
1030
- <div className="px-2 py-2 text-center text-[11px] text-agent-muted-foreground">
1031
- {isLoadingMoreChats ? (
1032
- <span className="inline-flex items-center gap-1">
1033
- <LuLoaderCircle className="h-3 w-3 animate-spin" />
1034
- 加载更多...
1035
- </span>
1036
- ) : hasMoreChats ? (
1037
- "继续下滑加载更多"
1038
- ) : (
1039
- `共 ${normalizedChats.length} 个会话`
1040
1159
  )}
1041
1160
  </div>
1042
- )}
1043
- </div>
1044
- )}
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>
1045
1178
  </div>
1046
1179
 
1047
1180
  {(error || projectError) && (
@@ -1053,34 +1186,14 @@ export function AgentSidebar({
1053
1186
  </div>
1054
1187
  )}
1055
1188
 
1056
- {/* ───── Footer: 右侧面板切换(终端 | 包槽位)+ 设置 ───── */}
1189
+ {/* ───── Footer: 设置 ───── */}
1057
1190
  <div className="flex-shrink-0 border-t border-agent-border/40 px-2.5 py-1.5">
1058
- {showTerminalChrome ? (
1059
- <button
1060
- type="button"
1061
- onClick={() => onToggleRightPanel("terminal")}
1062
- className={[
1063
- "flex h-7 w-full items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors",
1064
- rightPanel === "terminal"
1065
- ? "bg-agent-foreground/10 font-medium text-agent-foreground"
1066
- : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground",
1067
- ].join(" ")}
1068
- title={`${rightPanel === "terminal" ? "关闭" : "打开"}终端面板 (${isMac ? "⌘T" : "Ctrl+T"})`}
1069
- data-testid="sidebar-terminal"
1070
- >
1071
- <LuTerminal className="h-3.5 w-3.5" />
1072
- <span>终端</span>
1073
- <span className="ml-auto text-[10px] text-agent-muted-foreground/70">
1074
- {isMac ? "⌘T" : "Ctrl+T"}
1075
- </span>
1076
- </button>
1077
- ) : null}
1078
1191
  {hasGeneralSettingsChrome() && (
1079
1192
  <button
1080
1193
  type="button"
1081
1194
  onClick={() => navigate("/settings")}
1082
1195
  className={[
1083
- "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",
1084
1197
  onGeneralSettings
1085
1198
  ? "bg-agent-foreground/10 font-medium text-agent-foreground"
1086
1199
  : "text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground",