@steerable/agent-shell-web 0.6.15
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.
- package/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.base.ts +94 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentSidebar — parity rewrite that mirrors the visual + interaction
|
|
3
|
+
* surface of `deeppath/apps/web/src/app/agent/AgentSidebar.tsx`.
|
|
4
|
+
*
|
|
5
|
+
* ## Agent picker semantics (this was a UX bug — read before changing!)
|
|
6
|
+
*
|
|
7
|
+
* Two distinct concepts share the agent list, and conflating them was the
|
|
8
|
+
* original sin of the first cut:
|
|
9
|
+
*
|
|
10
|
+
* • **selectedAgentId** — "which agent will the NEXT `+ 新对话` use".
|
|
11
|
+
* Driven by user clicks in the sidebar. Persists until the user clicks
|
|
12
|
+
* another agent. Defaults to this flavor's builtin expert on first load
|
|
13
|
+
* (场景产品 → 包的主打专家;无包产品 → shell 默认智能体).
|
|
14
|
+
* • **activeChatAgentId** — "which agent the CURRENT chat is bound to"
|
|
15
|
+
* (URL-driven, can't be re-bound without a backend endpoint). Purely
|
|
16
|
+
* informational in the sidebar — there's no UI for switching mid-chat
|
|
17
|
+
* yet.
|
|
18
|
+
*
|
|
19
|
+
* The original code computed `displayAgentId = activeChatAgentId ?? selectedAgentId`
|
|
20
|
+
* and lit up THAT row. Result: when the user was inside a chat and clicked a
|
|
21
|
+
* DIFFERENT agent, the click silently mutated `selectedAgentId`, but the
|
|
22
|
+
* highlight stayed pinned to the chat's existing agent → the user saw zero
|
|
23
|
+
* feedback and assumed the button was broken. They only noticed selection
|
|
24
|
+
* worked after clicking `+ 新对话` and seeing the new agent in the new chat.
|
|
25
|
+
*
|
|
26
|
+
* Current behavior:
|
|
27
|
+
* 1. **Row highlight always tracks `selectedAgentId`** — click = visible
|
|
28
|
+
* response, no exceptions.
|
|
29
|
+
* 2. **Current chat's agent gets a "当前" pill** — small badge to the
|
|
30
|
+
* right of the row, never blocks selection feedback.
|
|
31
|
+
* 3. **Navigating to a chat auto-syncs `selectedAgentId = chat.agentId`**
|
|
32
|
+
* via a useEffect on `activeChatAgentId`. This way the user's mental
|
|
33
|
+
* model "I'm working with agent X" stays consistent: switching chat
|
|
34
|
+
* switches the selection; clicking an agent overrides it.
|
|
35
|
+
* 4. **When `selectedAgentId !== activeChatAgentId`** (the user has
|
|
36
|
+
* explicitly diverged), an inline `+ 用「name」新建对话` CTA appears
|
|
37
|
+
* below the agent list. This makes the "your click set up a new chat"
|
|
38
|
+
* contract impossible to miss.
|
|
39
|
+
*
|
|
40
|
+
* ## Other intentional differences from the cloud sibling
|
|
41
|
+
*
|
|
42
|
+
* • Active chat id comes from the URL (`useParams().chatId`) rather than
|
|
43
|
+
* a Context (we drive navigation, not vice versa).
|
|
44
|
+
* • Agent CRUD lives on `/settings?section=agents`(侧栏「智能体管理」),
|
|
45
|
+
* not a modal. Custom agents show up in ChatInput's expert picker.
|
|
46
|
+
* • Cmd+N / Cmd+T trigger `menu:new-chat` / `menu:open-terminal` via the
|
|
47
|
+
* preload bridge; we subscribe here so the shortcuts work regardless of
|
|
48
|
+
* focused window. The terminal is a toggleable panel BESIDE the chat
|
|
49
|
+
* (owned by AgentLayout), not a route and not a separate window.
|
|
50
|
+
*
|
|
51
|
+
* Layout map:
|
|
52
|
+
*
|
|
53
|
+
* ┌─────────────────────────────────┐
|
|
54
|
+
* │ ✨ Product Agent +• │ ← + has a color dot of the selected agent
|
|
55
|
+
* ├─────────────────────────────────┤
|
|
56
|
+
* │ v 专家团队 · 3 │
|
|
57
|
+
* │ ● 教练 (selected) │ ← highlight = selectedAgentId
|
|
58
|
+
* │ ● 文学顾问 · 当前 │ ← "当前" pill = activeChatAgentId
|
|
59
|
+
* │ ● 哲学家 │
|
|
60
|
+
* │ + 用「教练」新建对话 │ ← inline CTA when sel ≠ current
|
|
61
|
+
* ├──── divider ────────────────────┤
|
|
62
|
+
* │ + 新对话 │ ← 只打开落地页,有内容才落库
|
|
63
|
+
* │ ⬡ 智能体管理 │ ← /settings?section=agents(独立页)
|
|
64
|
+
* │ ⬡ Skill 设置 │ ← /settings?section=skills(独立页)
|
|
65
|
+
* │ 🔌 MCP 设置 │ ← /settings?section=mcp(独立页)
|
|
66
|
+
* │ v 会话 · 12 📁+ │ ← 📁+ 新建项目
|
|
67
|
+
* │ v 📁 项目A · 3 (hover: +✏📂🗑)│ ← 项目组:折叠/新建/重命名/换文件夹/删
|
|
68
|
+
* │ ...(项目内对话) │
|
|
69
|
+
* │ 今天 │
|
|
70
|
+
* │ ...(无项目对话,按日期分组) │ ← 无项目排在项目分组之后
|
|
71
|
+
* ├─────────────────────────────────┤
|
|
72
|
+
* │ ▢_ 终端 ⌘T │
|
|
73
|
+
* │ ⚙ 设置 │ ← /settings(模型 + 洞察/遥测/用量/搜索/安全)
|
|
74
|
+
* └─────────────────────────────────┘
|
|
75
|
+
*
|
|
76
|
+
* 项目模式:项目 = 名字 + 绑定文件夹(ProjectRegistry,electron-store)。
|
|
77
|
+
* 项目内对话的 agent 文件读写与命令执行被硬沙箱在该文件夹内(见
|
|
78
|
+
* tool-router.ts ToolExecContext.projectRoot);无项目对话不沙箱。
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
import {
|
|
82
|
+
useCallback,
|
|
83
|
+
useEffect,
|
|
84
|
+
useMemo,
|
|
85
|
+
useRef,
|
|
86
|
+
useState,
|
|
87
|
+
} from 'react';
|
|
88
|
+
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
|
89
|
+
import {
|
|
90
|
+
LuChevronDown,
|
|
91
|
+
LuChevronUp,
|
|
92
|
+
LuMessageSquare,
|
|
93
|
+
LuCloudCog,
|
|
94
|
+
LuTrash2,
|
|
95
|
+
LuLoaderCircle,
|
|
96
|
+
LuTerminal,
|
|
97
|
+
LuSettings,
|
|
98
|
+
LuPlus,
|
|
99
|
+
LuPanelLeftClose,
|
|
100
|
+
LuBlocks,
|
|
101
|
+
LuBot,
|
|
102
|
+
LuFolder,
|
|
103
|
+
LuFolderOpen,
|
|
104
|
+
LuFolderPlus,
|
|
105
|
+
LuPencil,
|
|
106
|
+
LuPlug,
|
|
107
|
+
} from 'react-icons/lu';
|
|
108
|
+
import { parseChatTitle } from '@/lib/chat-title';
|
|
109
|
+
import { getDateGroupLabel, getDateGroupPriority } from '@/lib/date-groups';
|
|
110
|
+
import { getElectronBridge, isElectron } from '@/lib/electron-bridge';
|
|
111
|
+
import {
|
|
112
|
+
createProject,
|
|
113
|
+
deleteProject,
|
|
114
|
+
listProjects,
|
|
115
|
+
updateProject,
|
|
116
|
+
type LocalChat,
|
|
117
|
+
type LocalChatAgent,
|
|
118
|
+
type LocalProject,
|
|
119
|
+
} from '@/lib/local-api';
|
|
120
|
+
import type { UseChatsAndAgentsResult } from '@/hooks/useChatsAndAgents';
|
|
121
|
+
import type { PackChatSlotContribution } from '@/packs/registry';
|
|
122
|
+
import type { RightPanelState } from '@/layouts/AgentLayout';
|
|
123
|
+
// 必须 import 而不是写 src="/favicon.png":public/ 下的资源 Vite 永远按绝对路径
|
|
124
|
+
// /favicon.png 输出,dev 模式下 dev server 提供根路径所以能加载,但打包成
|
|
125
|
+
// Electron 后渲染进程走 file:// 协议,/favicon.png 会被解析成文件系统根目录
|
|
126
|
+
// 下的 favicon.png(必然 404)。改成模块导入后,Vite 会把图片放到 dist/assets
|
|
127
|
+
// 下并发出 base-relative URL,配合 vite.config.ts 的 `base: './'` 在两种模式
|
|
128
|
+
// 下都能正确加载。
|
|
129
|
+
import { getBrandLogoUrl, BRAND_NAME } from '@/brand';
|
|
130
|
+
|
|
131
|
+
const DEFAULT_DOT_COLOR = '#7c3aed';
|
|
132
|
+
|
|
133
|
+
function agentInitial(agent: LocalChatAgent | null): string {
|
|
134
|
+
const name = (agent?.name || '').trim();
|
|
135
|
+
return name ? name[0].toUpperCase() : 'A';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function AgentDot({
|
|
139
|
+
agent,
|
|
140
|
+
size = 18,
|
|
141
|
+
}: {
|
|
142
|
+
agent: LocalChatAgent | null;
|
|
143
|
+
size?: number;
|
|
144
|
+
}) {
|
|
145
|
+
const color = agent?.color || DEFAULT_DOT_COLOR;
|
|
146
|
+
return (
|
|
147
|
+
<span
|
|
148
|
+
className="inline-flex flex-shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white shadow-sm"
|
|
149
|
+
style={{ width: size, height: size, backgroundColor: color }}
|
|
150
|
+
>
|
|
151
|
+
{agentInitial(agent)}
|
|
152
|
+
</span>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface AgentSidebarProps {
|
|
157
|
+
data: UseChatsAndAgentsResult;
|
|
158
|
+
/** 右侧栏当前打开的面板:null / 'terminal' / 包槽位 id(互斥)。 */
|
|
159
|
+
rightPanel: RightPanelState;
|
|
160
|
+
/** 切换右侧栏面板显隐——面板不是路由也不是独立窗口,只是布局里的一栏。 */
|
|
161
|
+
onToggleRightPanel: (kind: string) => void;
|
|
162
|
+
/** 包注册的聊天页槽位(分段控件里每个槽位一个按钮);空 = 只有终端。 */
|
|
163
|
+
chatSlots: readonly PackChatSlotContribution[];
|
|
164
|
+
/** 收起侧边栏(AgentLayout 换成窄 rail,展开按钮在 rail 上)。 */
|
|
165
|
+
onCollapse: () => void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function AgentSidebar({
|
|
169
|
+
data,
|
|
170
|
+
rightPanel,
|
|
171
|
+
onToggleRightPanel,
|
|
172
|
+
chatSlots,
|
|
173
|
+
onCollapse,
|
|
174
|
+
}: AgentSidebarProps) {
|
|
175
|
+
const navigate = useNavigate();
|
|
176
|
+
const location = useLocation();
|
|
177
|
+
const { chatId: currentChatId } = useParams<{ chatId?: string }>();
|
|
178
|
+
const bridge = getElectronBridge();
|
|
179
|
+
const onSettingsPage = location.pathname === '/settings';
|
|
180
|
+
// /settings?section=skills|mcp|agents|(缺省 = 综合设置)—— 各自高亮。
|
|
181
|
+
const settingsSection = useMemo(
|
|
182
|
+
() => new URLSearchParams(location.search).get('section'),
|
|
183
|
+
[location.search],
|
|
184
|
+
);
|
|
185
|
+
const onGeneralSettings =
|
|
186
|
+
onSettingsPage &&
|
|
187
|
+
settingsSection !== 'skills' &&
|
|
188
|
+
settingsSection !== 'mcp' &&
|
|
189
|
+
settingsSection !== 'agents';
|
|
190
|
+
const onNewChatHome = !currentChatId && !onSettingsPage;
|
|
191
|
+
|
|
192
|
+
const {
|
|
193
|
+
chats,
|
|
194
|
+
agents,
|
|
195
|
+
isLoading: isChatLoading,
|
|
196
|
+
error,
|
|
197
|
+
setSelectedAgentId,
|
|
198
|
+
deleteChat,
|
|
199
|
+
isLoadingMoreChats,
|
|
200
|
+
hasMoreChats,
|
|
201
|
+
loadMoreChats,
|
|
202
|
+
} = data;
|
|
203
|
+
|
|
204
|
+
const [isMac, setIsMac] = useState(false);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (typeof navigator !== 'undefined') {
|
|
208
|
+
setIsMac(/Mac|iPod|iPhone|iPad/.test(navigator.platform));
|
|
209
|
+
}
|
|
210
|
+
}, []);
|
|
211
|
+
const [confirmDeleteChatId, setConfirmDeleteChatId] = useState<string | null>(
|
|
212
|
+
null,
|
|
213
|
+
);
|
|
214
|
+
const [deletingChatId, setDeletingChatId] = useState<string | null>(null);
|
|
215
|
+
const [chatsExpanded, setChatsExpanded] = useState(true);
|
|
216
|
+
const chatScrollRef = useRef<HTMLDivElement>(null);
|
|
217
|
+
|
|
218
|
+
// ───── 项目模式 ─────
|
|
219
|
+
// 项目列表从 local-backend 拉取(electron-store 持久化)。会话按
|
|
220
|
+
// projectId 分组:项目分组在上(每个项目一个可折叠分组,组头支持内联
|
|
221
|
+
// 管理:新建对话 / 重命名 / 换文件夹 / 删除),无项目对话在下(日期分组)。
|
|
222
|
+
const [projects, setProjects] = useState<LocalProject[]>([]);
|
|
223
|
+
const [collapsedProjectIds, setCollapsedProjectIds] = useState<Set<string>>(
|
|
224
|
+
() => new Set(),
|
|
225
|
+
);
|
|
226
|
+
const [renamingProjectId, setRenamingProjectId] = useState<string | null>(null);
|
|
227
|
+
const [renamingValue, setRenamingValue] = useState('');
|
|
228
|
+
const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null);
|
|
229
|
+
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
|
230
|
+
const [projectError, setProjectError] = useState<string | null>(null);
|
|
231
|
+
|
|
232
|
+
const fetchProjects = useCallback(async () => {
|
|
233
|
+
if (!isElectron()) return;
|
|
234
|
+
try {
|
|
235
|
+
const res = await listProjects();
|
|
236
|
+
setProjects(res.projects || []);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.error('获取项目列表失败:', err);
|
|
239
|
+
setProjectError(err instanceof Error ? err.message : String(err));
|
|
240
|
+
}
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
void fetchProjects();
|
|
245
|
+
}, [fetchProjects]);
|
|
246
|
+
|
|
247
|
+
const handleCreateProject = useCallback(async () => {
|
|
248
|
+
if (!isElectron()) return;
|
|
249
|
+
setProjectError(null);
|
|
250
|
+
try {
|
|
251
|
+
const result = await bridge?.local?.selectDirectory({
|
|
252
|
+
title: '选择项目文件夹',
|
|
253
|
+
});
|
|
254
|
+
if (!result || result.canceled || result.filePaths.length === 0) return;
|
|
255
|
+
const folderPath = result.filePaths[0];
|
|
256
|
+
// 默认用文件夹名做项目名,用户可随后内联重命名。
|
|
257
|
+
const baseName =
|
|
258
|
+
folderPath.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '新项目';
|
|
259
|
+
await createProject({ name: baseName, folderPath });
|
|
260
|
+
await fetchProjects();
|
|
261
|
+
} catch (err) {
|
|
262
|
+
setProjectError(err instanceof Error ? err.message : String(err));
|
|
263
|
+
}
|
|
264
|
+
}, [bridge, fetchProjects]);
|
|
265
|
+
|
|
266
|
+
const handleRenameProject = useCallback(
|
|
267
|
+
async (projectId: string) => {
|
|
268
|
+
const name = renamingValue.trim();
|
|
269
|
+
setRenamingProjectId(null);
|
|
270
|
+
if (!name) return;
|
|
271
|
+
try {
|
|
272
|
+
await updateProject(projectId, { name });
|
|
273
|
+
await fetchProjects();
|
|
274
|
+
} catch (err) {
|
|
275
|
+
setProjectError(err instanceof Error ? err.message : String(err));
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
[renamingValue, fetchProjects],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const handleChangeProjectFolder = useCallback(
|
|
282
|
+
async (projectId: string) => {
|
|
283
|
+
if (!isElectron()) return;
|
|
284
|
+
setProjectError(null);
|
|
285
|
+
try {
|
|
286
|
+
const result = await bridge?.local?.selectDirectory({
|
|
287
|
+
title: '重新选择项目文件夹',
|
|
288
|
+
});
|
|
289
|
+
if (!result || result.canceled || result.filePaths.length === 0) return;
|
|
290
|
+
await updateProject(projectId, { folderPath: result.filePaths[0] });
|
|
291
|
+
await fetchProjects();
|
|
292
|
+
} catch (err) {
|
|
293
|
+
setProjectError(err instanceof Error ? err.message : String(err));
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[bridge, fetchProjects],
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const handleDeleteProject = useCallback(
|
|
300
|
+
async (projectId: string) => {
|
|
301
|
+
if (deletingProjectId === projectId) return;
|
|
302
|
+
// 与会话删除同款两段确认:第一次点击武装红色按钮,第二次才真删。
|
|
303
|
+
if (confirmDeleteProjectId !== projectId) {
|
|
304
|
+
setConfirmDeleteProjectId(projectId);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
setDeletingProjectId(projectId);
|
|
309
|
+
// 后端会把该项目下的会话降级为无项目对话(不删会话)。
|
|
310
|
+
await deleteProject(projectId);
|
|
311
|
+
setConfirmDeleteProjectId(null);
|
|
312
|
+
await fetchProjects();
|
|
313
|
+
await data.refreshChats();
|
|
314
|
+
} catch (err) {
|
|
315
|
+
setProjectError(err instanceof Error ? err.message : String(err));
|
|
316
|
+
} finally {
|
|
317
|
+
setDeletingProjectId(null);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
[confirmDeleteProjectId, deletingProjectId, fetchProjects, data],
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const toggleProjectCollapsed = useCallback((projectId: string) => {
|
|
324
|
+
setCollapsedProjectIds((prev) => {
|
|
325
|
+
const next = new Set(prev);
|
|
326
|
+
if (next.has(projectId)) next.delete(projectId);
|
|
327
|
+
else next.add(projectId);
|
|
328
|
+
return next;
|
|
329
|
+
});
|
|
330
|
+
}, []);
|
|
331
|
+
|
|
332
|
+
// Keep the shared "next new chat" agent aligned with the current chat. The
|
|
333
|
+
// actual picker now lives in ChatInput, so the sidebar no longer renders the
|
|
334
|
+
// expert list itself.
|
|
335
|
+
const activeChatAgentId = useMemo(() => {
|
|
336
|
+
if (!currentChatId) return null;
|
|
337
|
+
return chats.find((c) => c.id === currentChatId)?.agentId ?? null;
|
|
338
|
+
}, [chats, currentChatId]);
|
|
339
|
+
|
|
340
|
+
// Auto-sync `selectedAgentId` when the user navigates to a chat. Without
|
|
341
|
+
// this, the user lands in chat A (built on agent X), opens the sidebar,
|
|
342
|
+
// sees agent Y still highlighted from a stale selection, and gets confused
|
|
343
|
+
// about which agent is "active". Following the URL keeps the mental model
|
|
344
|
+
// straight: "the agent I'm currently working with is the highlighted one".
|
|
345
|
+
// Any explicit click in the sidebar overrides this back to the clicked
|
|
346
|
+
// agent (see handlePickAgent).
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (activeChatAgentId) setSelectedAgentId(activeChatAgentId);
|
|
349
|
+
}, [activeChatAgentId, setSelectedAgentId]);
|
|
350
|
+
|
|
351
|
+
// 「新对话」只打开落地页,不落库。有内容才在 EmptyChatGate 提交时
|
|
352
|
+
// createChat。项目组头的「+」带上 projectId,落地页预选该项目。
|
|
353
|
+
const handleOpenNewChat = useCallback(
|
|
354
|
+
(projectId?: string) => {
|
|
355
|
+
if (projectId) {
|
|
356
|
+
navigate(`/agent?projectId=${encodeURIComponent(projectId)}`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
navigate('/agent');
|
|
360
|
+
},
|
|
361
|
+
[navigate],
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const normalizedChats = useMemo(() => {
|
|
365
|
+
return chats
|
|
366
|
+
.map((chat) => {
|
|
367
|
+
const updated = chat.updatedAt ? new Date(chat.updatedAt) : null;
|
|
368
|
+
const created = new Date(chat.createdAt);
|
|
369
|
+
const sortDate =
|
|
370
|
+
updated && !Number.isNaN(updated.getTime())
|
|
371
|
+
? updated
|
|
372
|
+
: !Number.isNaN(created.getTime())
|
|
373
|
+
? created
|
|
374
|
+
: new Date();
|
|
375
|
+
const { displayTitle, isAutomation } = parseChatTitle(chat.title);
|
|
376
|
+
return {
|
|
377
|
+
id: chat.id,
|
|
378
|
+
title: displayTitle || '新会话',
|
|
379
|
+
isAutomation,
|
|
380
|
+
isPinned: chat.isPinned,
|
|
381
|
+
sortDate,
|
|
382
|
+
agentId: chat.agentId ?? null,
|
|
383
|
+
projectId: chat.projectId ?? null,
|
|
384
|
+
};
|
|
385
|
+
})
|
|
386
|
+
.sort((a, b) => {
|
|
387
|
+
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
|
388
|
+
return b.sortDate.getTime() - a.sortDate.getTime();
|
|
389
|
+
});
|
|
390
|
+
}, [chats]);
|
|
391
|
+
|
|
392
|
+
// 顶层按项目分组:无项目对话(含项目已被删但列表还没刷新的孤儿会话)
|
|
393
|
+
// 保持原有的日期分组;每个项目一个分组,组内按 pin + 时间排序。
|
|
394
|
+
const knownProjectIds = useMemo(
|
|
395
|
+
() => new Set(projects.map((p) => p.id)),
|
|
396
|
+
[projects],
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const noProjectChats = useMemo(
|
|
400
|
+
() =>
|
|
401
|
+
normalizedChats.filter(
|
|
402
|
+
(c) => !c.projectId || !knownProjectIds.has(c.projectId),
|
|
403
|
+
),
|
|
404
|
+
[normalizedChats, knownProjectIds],
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const projectGroups = useMemo(
|
|
408
|
+
() =>
|
|
409
|
+
projects.map((project) => ({
|
|
410
|
+
project,
|
|
411
|
+
items: normalizedChats.filter((c) => c.projectId === project.id),
|
|
412
|
+
})),
|
|
413
|
+
[projects, normalizedChats],
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const chatGroups = useMemo(() => {
|
|
417
|
+
const map = new Map<
|
|
418
|
+
string,
|
|
419
|
+
{
|
|
420
|
+
label: string;
|
|
421
|
+
priority: number;
|
|
422
|
+
items: typeof normalizedChats;
|
|
423
|
+
}
|
|
424
|
+
>();
|
|
425
|
+
noProjectChats.forEach((chat) => {
|
|
426
|
+
const label = getDateGroupLabel(chat.sortDate);
|
|
427
|
+
if (!map.has(label)) {
|
|
428
|
+
map.set(label, {
|
|
429
|
+
label,
|
|
430
|
+
priority: getDateGroupPriority(label),
|
|
431
|
+
items: [],
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
map.get(label)!.items.push(chat);
|
|
435
|
+
});
|
|
436
|
+
return Array.from(map.values()).sort((a, b) => a.priority - b.priority);
|
|
437
|
+
}, [noProjectChats]);
|
|
438
|
+
|
|
439
|
+
const handleDeleteChat = useCallback(
|
|
440
|
+
async (id: string) => {
|
|
441
|
+
if (deletingChatId === id) return;
|
|
442
|
+
// Two-step confirm — first click arms the red button, second click
|
|
443
|
+
// actually deletes. Matches the cloud sibling exactly so users don't
|
|
444
|
+
// get muscle-memory whiplash.
|
|
445
|
+
if (confirmDeleteChatId !== id) {
|
|
446
|
+
setConfirmDeleteChatId(id);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
setDeletingChatId(id);
|
|
451
|
+
const ok = await deleteChat(id);
|
|
452
|
+
if (ok) {
|
|
453
|
+
setConfirmDeleteChatId(null);
|
|
454
|
+
if (id === currentChatId) navigate('/agent');
|
|
455
|
+
}
|
|
456
|
+
} finally {
|
|
457
|
+
setDeletingChatId(null);
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
[confirmDeleteChatId, deleteChat, deletingChatId, currentChatId, navigate],
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// 单条会话行——无项目日期分组和项目分组共用同一个渲染,避免两份 JSX。
|
|
464
|
+
const renderChatRow = (chat: (typeof normalizedChats)[number]) => {
|
|
465
|
+
const isCurrent = currentChatId === chat.id;
|
|
466
|
+
const isConfirmingDelete = confirmDeleteChatId === chat.id;
|
|
467
|
+
const isDeleting = deletingChatId === chat.id;
|
|
468
|
+
const chatAgent = agents.find((a) => a.id === chat.agentId) ?? null;
|
|
469
|
+
return (
|
|
470
|
+
<div
|
|
471
|
+
key={chat.id}
|
|
472
|
+
className="group/item relative"
|
|
473
|
+
data-testid="sidebar-chat-row"
|
|
474
|
+
data-chat-id={chat.id}
|
|
475
|
+
>
|
|
476
|
+
<button
|
|
477
|
+
type="button"
|
|
478
|
+
onClick={() => {
|
|
479
|
+
setConfirmDeleteChatId(null);
|
|
480
|
+
navigate(`/agent/${chat.id}`);
|
|
481
|
+
}}
|
|
482
|
+
className={[
|
|
483
|
+
'flex h-8 w-full min-w-0 items-center gap-1.5 rounded-full px-3 text-sm transition-colors duration-200',
|
|
484
|
+
isCurrent
|
|
485
|
+
? 'bg-agent-foreground/10 font-medium text-agent-foreground'
|
|
486
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
487
|
+
].join(' ')}
|
|
488
|
+
title={
|
|
489
|
+
chat.isAutomation
|
|
490
|
+
? `[由自动化触发] ${chat.title}`
|
|
491
|
+
: chat.title
|
|
492
|
+
}
|
|
493
|
+
>
|
|
494
|
+
{chat.isPinned && (
|
|
495
|
+
<span
|
|
496
|
+
className="shrink-0 text-[10px]"
|
|
497
|
+
aria-label="已置顶"
|
|
498
|
+
>
|
|
499
|
+
📌
|
|
500
|
+
</span>
|
|
501
|
+
)}
|
|
502
|
+
{chat.isAutomation && (
|
|
503
|
+
<LuCloudCog
|
|
504
|
+
className="h-3 w-3 shrink-0 text-agent-muted-foreground"
|
|
505
|
+
aria-label="由自动化触发"
|
|
506
|
+
/>
|
|
507
|
+
)}
|
|
508
|
+
<AgentDot agent={chatAgent} size={16} />
|
|
509
|
+
<span className="min-w-0 truncate leading-none">
|
|
510
|
+
{chat.title}
|
|
511
|
+
</span>
|
|
512
|
+
</button>
|
|
513
|
+
{/* Gradient mask so the trash button doesn't paint
|
|
514
|
+
over the chat title — fade matches the row's
|
|
515
|
+
own background (canvas when selected, muted when
|
|
516
|
+
hovered). */}
|
|
517
|
+
<div
|
|
518
|
+
aria-hidden="true"
|
|
519
|
+
className={[
|
|
520
|
+
'pointer-events-none absolute inset-y-0 right-0 w-12 rounded-r-full transition-opacity duration-200',
|
|
521
|
+
isConfirmingDelete
|
|
522
|
+
? isCurrent
|
|
523
|
+
? 'bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-100'
|
|
524
|
+
: 'bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-100'
|
|
525
|
+
: isCurrent
|
|
526
|
+
? 'bg-gradient-to-l from-agent-canvas via-agent-canvas/95 to-transparent opacity-0 group-hover/item:opacity-100'
|
|
527
|
+
: 'bg-gradient-to-l from-agent-muted via-agent-muted/95 to-transparent opacity-0 group-hover/item:opacity-100',
|
|
528
|
+
].join(' ')}
|
|
529
|
+
/>
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
onClick={(event) => {
|
|
533
|
+
event.stopPropagation();
|
|
534
|
+
void handleDeleteChat(chat.id);
|
|
535
|
+
}}
|
|
536
|
+
disabled={isDeleting}
|
|
537
|
+
className={[
|
|
538
|
+
'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',
|
|
539
|
+
isConfirmingDelete
|
|
540
|
+
? 'bg-agent-destructive/10 text-agent-destructive opacity-100 hover:bg-agent-destructive/20'
|
|
541
|
+
: 'text-agent-muted-foreground opacity-0 hover:bg-agent-foreground/5 hover:text-agent-destructive group-hover/item:opacity-100 focus:opacity-100',
|
|
542
|
+
'disabled:cursor-not-allowed disabled:opacity-100',
|
|
543
|
+
].join(' ')}
|
|
544
|
+
title={
|
|
545
|
+
isConfirmingDelete
|
|
546
|
+
? '再次点击确认删除'
|
|
547
|
+
: '删除会话'
|
|
548
|
+
}
|
|
549
|
+
aria-label={
|
|
550
|
+
isConfirmingDelete
|
|
551
|
+
? '再次点击确认删除'
|
|
552
|
+
: '删除会话'
|
|
553
|
+
}
|
|
554
|
+
data-testid="sidebar-chat-delete"
|
|
555
|
+
>
|
|
556
|
+
<LuTrash2
|
|
557
|
+
className={[
|
|
558
|
+
'h-3.5 w-3.5',
|
|
559
|
+
isDeleting ? 'animate-pulse' : '',
|
|
560
|
+
].join(' ')}
|
|
561
|
+
/>
|
|
562
|
+
</button>
|
|
563
|
+
</div>
|
|
564
|
+
);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
// Cmd+N from the app menu — wired in src/main.ts:createMenu (sends
|
|
568
|
+
// `menu:new-chat`). The bridge methods are optional because non-Electron
|
|
569
|
+
// dev previews don't have them.
|
|
570
|
+
useEffect(() => {
|
|
571
|
+
if (!bridge?.onMenuNewChat) return;
|
|
572
|
+
bridge.onMenuNewChat(() => {
|
|
573
|
+
handleOpenNewChat();
|
|
574
|
+
});
|
|
575
|
+
return () => {
|
|
576
|
+
bridge.offMenuNewChat?.();
|
|
577
|
+
};
|
|
578
|
+
}, [bridge, handleOpenNewChat]);
|
|
579
|
+
|
|
580
|
+
// Auto-load next page when the chat scroller approaches the bottom.
|
|
581
|
+
// Throttled by `isLoadingMoreChats` inside the hook.
|
|
582
|
+
useEffect(() => {
|
|
583
|
+
const container = chatScrollRef.current;
|
|
584
|
+
if (!container || !chatsExpanded) return;
|
|
585
|
+
|
|
586
|
+
const maybeLoadMore = () => {
|
|
587
|
+
if (!hasMoreChats || isLoadingMoreChats) return;
|
|
588
|
+
const nearBottom =
|
|
589
|
+
container.scrollTop + container.clientHeight >=
|
|
590
|
+
container.scrollHeight - 60;
|
|
591
|
+
if (nearBottom) void loadMoreChats();
|
|
592
|
+
};
|
|
593
|
+
container.addEventListener('scroll', maybeLoadMore, { passive: true });
|
|
594
|
+
const tickId = window.requestAnimationFrame(maybeLoadMore);
|
|
595
|
+
return () => {
|
|
596
|
+
container.removeEventListener('scroll', maybeLoadMore);
|
|
597
|
+
window.cancelAnimationFrame(tickId);
|
|
598
|
+
};
|
|
599
|
+
}, [
|
|
600
|
+
hasMoreChats,
|
|
601
|
+
isLoadingMoreChats,
|
|
602
|
+
loadMoreChats,
|
|
603
|
+
chatsExpanded,
|
|
604
|
+
normalizedChats.length,
|
|
605
|
+
]);
|
|
606
|
+
|
|
607
|
+
// Cmd+T from the app menu — wired in src/main.ts:createMenu (sends
|
|
608
|
+
// `menu:open-terminal`). Same pattern as `menu:new-chat` above. The
|
|
609
|
+
// terminal is a toggleable panel beside the chat, so this just flips
|
|
610
|
+
// the layout state owned by AgentLayout.
|
|
611
|
+
useEffect(() => {
|
|
612
|
+
if (!bridge?.onMenuOpenTerminal) return;
|
|
613
|
+
bridge.onMenuOpenTerminal(() => {
|
|
614
|
+
onToggleRightPanel('terminal');
|
|
615
|
+
});
|
|
616
|
+
return () => {
|
|
617
|
+
bridge.offMenuOpenTerminal?.();
|
|
618
|
+
};
|
|
619
|
+
}, [bridge, onToggleRightPanel]);
|
|
620
|
+
|
|
621
|
+
const hasElectron = isElectron();
|
|
622
|
+
|
|
623
|
+
return (
|
|
624
|
+
<div className="flex h-full w-full flex-col border-r border-agent-border/60 bg-agent-muted/70 backdrop-blur-md">
|
|
625
|
+
{/* ───── Brand + actions ───── */}
|
|
626
|
+
<div className="flex h-12 flex-shrink-0 items-center justify-between px-3">
|
|
627
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
628
|
+
{/*
|
|
629
|
+
品牌 logo —— src 用 module-imported asset,不要写 /favicon.png(见
|
|
630
|
+
文件顶部 import 的注释)。shell 默认是中性通用图标;产品品牌
|
|
631
|
+
logo 由激活包的 web 模块在注册时注入(getBrandLogoUrl)。
|
|
632
|
+
*/}
|
|
633
|
+
<img
|
|
634
|
+
src={getBrandLogoUrl()}
|
|
635
|
+
alt={BRAND_NAME}
|
|
636
|
+
className="h-6 w-6 flex-shrink-0 select-none"
|
|
637
|
+
draggable={false}
|
|
638
|
+
/>
|
|
639
|
+
<span className="truncate text-sm font-semibold tracking-tight text-agent-foreground">
|
|
640
|
+
{BRAND_NAME}
|
|
641
|
+
</span>
|
|
642
|
+
</div>
|
|
643
|
+
<div className="flex items-center gap-1">
|
|
644
|
+
<button
|
|
645
|
+
type="button"
|
|
646
|
+
onClick={onCollapse}
|
|
647
|
+
className="flex h-7 w-7 items-center justify-center rounded-full text-agent-muted-foreground transition-colors duration-200 hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
648
|
+
title="收起侧边栏"
|
|
649
|
+
aria-label="收起侧边栏"
|
|
650
|
+
>
|
|
651
|
+
<LuPanelLeftClose className="h-3.5 w-3.5" />
|
|
652
|
+
</button>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
{/* ───── 会话 ───── */}
|
|
657
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
658
|
+
{/* 新对话 + 智能体 / Skill / MCP 设置入口 — 横版行按钮,各自打开独立设置页 */}
|
|
659
|
+
<div className="flex-shrink-0 space-y-0.5 px-3 pb-0.5">
|
|
660
|
+
<button
|
|
661
|
+
type="button"
|
|
662
|
+
onClick={() => handleOpenNewChat()}
|
|
663
|
+
className={[
|
|
664
|
+
'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
|
|
665
|
+
onNewChatHome
|
|
666
|
+
? 'bg-agent-foreground/10 font-medium text-agent-foreground'
|
|
667
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
668
|
+
].join(' ')}
|
|
669
|
+
title="新建对话"
|
|
670
|
+
data-testid="sidebar-new-chat"
|
|
671
|
+
>
|
|
672
|
+
<LuPlus className="h-4 w-4" />
|
|
673
|
+
<span>新对话</span>
|
|
674
|
+
</button>
|
|
675
|
+
<button
|
|
676
|
+
type="button"
|
|
677
|
+
onClick={() => navigate('/settings?section=agents')}
|
|
678
|
+
className={[
|
|
679
|
+
'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
|
|
680
|
+
onSettingsPage && settingsSection === 'agents'
|
|
681
|
+
? 'bg-agent-foreground/10 font-medium text-agent-foreground'
|
|
682
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
683
|
+
].join(' ')}
|
|
684
|
+
title="智能体管理"
|
|
685
|
+
data-testid="sidebar-agent-settings"
|
|
686
|
+
>
|
|
687
|
+
<LuBot className="h-4 w-4" />
|
|
688
|
+
<span>智能体管理</span>
|
|
689
|
+
</button>
|
|
690
|
+
<button
|
|
691
|
+
type="button"
|
|
692
|
+
onClick={() => navigate('/settings?section=skills')}
|
|
693
|
+
className={[
|
|
694
|
+
'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
|
|
695
|
+
onSettingsPage && settingsSection === 'skills'
|
|
696
|
+
? 'bg-agent-foreground/10 font-medium text-agent-foreground'
|
|
697
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
698
|
+
].join(' ')}
|
|
699
|
+
title="Skill 设置"
|
|
700
|
+
data-testid="sidebar-skill-settings"
|
|
701
|
+
>
|
|
702
|
+
<LuBlocks className="h-4 w-4" />
|
|
703
|
+
<span>Skill 设置</span>
|
|
704
|
+
</button>
|
|
705
|
+
<button
|
|
706
|
+
type="button"
|
|
707
|
+
onClick={() => navigate('/settings?section=mcp')}
|
|
708
|
+
className={[
|
|
709
|
+
'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
|
|
710
|
+
onSettingsPage && settingsSection === 'mcp'
|
|
711
|
+
? 'bg-agent-foreground/10 font-medium text-agent-foreground'
|
|
712
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
713
|
+
].join(' ')}
|
|
714
|
+
title="MCP 设置"
|
|
715
|
+
data-testid="sidebar-mcp-settings"
|
|
716
|
+
>
|
|
717
|
+
<LuPlug className="h-4 w-4" />
|
|
718
|
+
<span>MCP 设置</span>
|
|
719
|
+
</button>
|
|
720
|
+
</div>
|
|
721
|
+
<div className="flex flex-shrink-0 items-center justify-between py-1.5 pl-4 pr-3">
|
|
722
|
+
<button
|
|
723
|
+
type="button"
|
|
724
|
+
onClick={() => setChatsExpanded((v) => !v)}
|
|
725
|
+
className="flex items-center text-xs font-semibold tracking-wider text-agent-muted-foreground transition-colors hover:text-agent-foreground"
|
|
726
|
+
>
|
|
727
|
+
<span className="mr-1">
|
|
728
|
+
{chatsExpanded ? (
|
|
729
|
+
<LuChevronUp className="h-3 w-3" />
|
|
730
|
+
) : (
|
|
731
|
+
<LuChevronDown className="h-3 w-3" />
|
|
732
|
+
)}
|
|
733
|
+
</span>
|
|
734
|
+
会话
|
|
735
|
+
{normalizedChats.length > 0 && (
|
|
736
|
+
<span className="ml-1.5 text-[10px] font-normal text-agent-muted-foreground/70">
|
|
737
|
+
· {normalizedChats.length}
|
|
738
|
+
</span>
|
|
739
|
+
)}
|
|
740
|
+
</button>
|
|
741
|
+
<div className="flex items-center gap-1">
|
|
742
|
+
{/* 新建项目:选文件夹 → 以文件夹名建项目,之后可在组头重命名 */}
|
|
743
|
+
{hasElectron && (
|
|
744
|
+
<button
|
|
745
|
+
type="button"
|
|
746
|
+
onClick={() => void handleCreateProject()}
|
|
747
|
+
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"
|
|
748
|
+
title="新建项目(绑定文件夹,项目内对话的文件操作被限制在该文件夹)"
|
|
749
|
+
aria-label="新建项目"
|
|
750
|
+
>
|
|
751
|
+
<LuFolderPlus className="h-3.5 w-3.5" />
|
|
752
|
+
</button>
|
|
753
|
+
)}
|
|
754
|
+
</div>
|
|
755
|
+
</div>
|
|
756
|
+
|
|
757
|
+
{chatsExpanded && (
|
|
758
|
+
<div
|
|
759
|
+
ref={chatScrollRef}
|
|
760
|
+
className="flex-1 overflow-y-auto px-2 pb-1"
|
|
761
|
+
>
|
|
762
|
+
{isChatLoading && chats.length === 0 && projects.length === 0 ? (
|
|
763
|
+
<div className="flex items-center justify-center py-4 text-xs text-agent-muted-foreground">
|
|
764
|
+
<LuLoaderCircle className="mr-1.5 h-3 w-3 animate-spin" />
|
|
765
|
+
加载中...
|
|
766
|
+
</div>
|
|
767
|
+
) : chatGroups.length === 0 && projectGroups.length === 0 ? (
|
|
768
|
+
<div className="flex flex-col items-center gap-1.5 py-6 text-xs text-agent-muted-foreground">
|
|
769
|
+
<LuMessageSquare className="h-4 w-4 text-agent-muted-foreground/60" />
|
|
770
|
+
暂无会话
|
|
771
|
+
</div>
|
|
772
|
+
) : (
|
|
773
|
+
<>
|
|
774
|
+
{/* 项目分组在前:组头可折叠,hover 出内联管理动作 */}
|
|
775
|
+
{projectGroups.map(({ project, items }) => (
|
|
776
|
+
<div key={project.id} className="mb-1">
|
|
777
|
+
<div className="group/proj relative">
|
|
778
|
+
{renamingProjectId === project.id ? (
|
|
779
|
+
<form
|
|
780
|
+
className="flex items-center px-3 pb-1 pt-2"
|
|
781
|
+
onSubmit={(event) => {
|
|
782
|
+
event.preventDefault();
|
|
783
|
+
void handleRenameProject(project.id);
|
|
784
|
+
}}
|
|
785
|
+
>
|
|
786
|
+
<input
|
|
787
|
+
autoFocus
|
|
788
|
+
value={renamingValue}
|
|
789
|
+
onChange={(event) => setRenamingValue(event.target.value)}
|
|
790
|
+
onBlur={() => void handleRenameProject(project.id)}
|
|
791
|
+
onKeyDown={(event) => {
|
|
792
|
+
if (event.key === 'Escape') setRenamingProjectId(null);
|
|
793
|
+
}}
|
|
794
|
+
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"
|
|
795
|
+
/>
|
|
796
|
+
</form>
|
|
797
|
+
) : (
|
|
798
|
+
<>
|
|
799
|
+
<button
|
|
800
|
+
type="button"
|
|
801
|
+
onClick={() => toggleProjectCollapsed(project.id)}
|
|
802
|
+
className="flex w-full min-w-0 items-center px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-agent-muted-foreground/80 transition-colors hover:text-agent-foreground"
|
|
803
|
+
title={`${project.name}\n${project.folderPath}`}
|
|
804
|
+
>
|
|
805
|
+
<span className="mr-1">
|
|
806
|
+
{collapsedProjectIds.has(project.id) ? (
|
|
807
|
+
<LuChevronDown className="h-3 w-3" />
|
|
808
|
+
) : (
|
|
809
|
+
<LuChevronUp className="h-3 w-3" />
|
|
810
|
+
)}
|
|
811
|
+
</span>
|
|
812
|
+
<LuFolder className="mr-1 h-3 w-3 shrink-0" />
|
|
813
|
+
<span className="min-w-0 truncate normal-case">
|
|
814
|
+
{project.name}
|
|
815
|
+
</span>
|
|
816
|
+
{items.length > 0 && (
|
|
817
|
+
<span className="ml-1 shrink-0 font-normal">
|
|
818
|
+
· {items.length}
|
|
819
|
+
</span>
|
|
820
|
+
)}
|
|
821
|
+
</button>
|
|
822
|
+
{/* 内联管理:新建对话 / 重命名 / 换文件夹 / 删除(两段确认) */}
|
|
823
|
+
<div className="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/proj:opacity-100">
|
|
824
|
+
<button
|
|
825
|
+
type="button"
|
|
826
|
+
onClick={() => handleOpenNewChat(project.id)}
|
|
827
|
+
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"
|
|
828
|
+
title="在此项目下新建对话"
|
|
829
|
+
>
|
|
830
|
+
<LuPlus className="h-3 w-3" />
|
|
831
|
+
</button>
|
|
832
|
+
<button
|
|
833
|
+
type="button"
|
|
834
|
+
onClick={() => {
|
|
835
|
+
setRenamingProjectId(project.id);
|
|
836
|
+
setRenamingValue(project.name);
|
|
837
|
+
}}
|
|
838
|
+
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"
|
|
839
|
+
title="重命名项目"
|
|
840
|
+
>
|
|
841
|
+
<LuPencil className="h-3 w-3" />
|
|
842
|
+
</button>
|
|
843
|
+
<button
|
|
844
|
+
type="button"
|
|
845
|
+
onClick={() => void handleChangeProjectFolder(project.id)}
|
|
846
|
+
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"
|
|
847
|
+
title="更换绑定文件夹"
|
|
848
|
+
>
|
|
849
|
+
<LuFolderOpen className="h-3 w-3" />
|
|
850
|
+
</button>
|
|
851
|
+
<button
|
|
852
|
+
type="button"
|
|
853
|
+
onClick={() => void handleDeleteProject(project.id)}
|
|
854
|
+
disabled={deletingProjectId === project.id}
|
|
855
|
+
className={`flex h-5 w-5 items-center justify-center rounded-full transition-colors ${
|
|
856
|
+
confirmDeleteProjectId === project.id
|
|
857
|
+
? 'bg-agent-destructive/10 text-agent-destructive hover:bg-agent-destructive/20'
|
|
858
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-destructive'
|
|
859
|
+
}`}
|
|
860
|
+
title={
|
|
861
|
+
confirmDeleteProjectId === project.id
|
|
862
|
+
? '再次点击确认删除(会话会保留为无项目对话)'
|
|
863
|
+
: '删除项目(会话保留为无项目对话)'
|
|
864
|
+
}
|
|
865
|
+
>
|
|
866
|
+
<LuTrash2
|
|
867
|
+
className={`h-3 w-3 ${deletingProjectId === project.id ? 'animate-pulse' : ''}`}
|
|
868
|
+
/>
|
|
869
|
+
</button>
|
|
870
|
+
</div>
|
|
871
|
+
</>
|
|
872
|
+
)}
|
|
873
|
+
</div>
|
|
874
|
+
{!collapsedProjectIds.has(project.id) && (
|
|
875
|
+
<div className="space-y-0.5">
|
|
876
|
+
{items.length === 0 ? (
|
|
877
|
+
<div className="px-3 py-1 text-[11px] text-agent-muted-foreground/60">
|
|
878
|
+
暂无会话 — hover 项目名点 + 新建
|
|
879
|
+
</div>
|
|
880
|
+
) : (
|
|
881
|
+
items.map((chat) => renderChatRow(chat))
|
|
882
|
+
)}
|
|
883
|
+
</div>
|
|
884
|
+
)}
|
|
885
|
+
</div>
|
|
886
|
+
))}
|
|
887
|
+
{/* 无项目对话排在项目分组之后:保持原有日期分组 */}
|
|
888
|
+
{chatGroups.map((group) => (
|
|
889
|
+
<div key={group.label} className="mb-1">
|
|
890
|
+
<div className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-agent-muted-foreground/80">
|
|
891
|
+
{group.label}
|
|
892
|
+
</div>
|
|
893
|
+
<div className="space-y-0.5">
|
|
894
|
+
{group.items.map((chat) => renderChatRow(chat))}
|
|
895
|
+
</div>
|
|
896
|
+
</div>
|
|
897
|
+
))}
|
|
898
|
+
</>
|
|
899
|
+
)}
|
|
900
|
+
{(chatGroups.length > 0 || projectGroups.length > 0) && (
|
|
901
|
+
<div className="px-2 py-2 text-center text-[11px] text-agent-muted-foreground">
|
|
902
|
+
{isLoadingMoreChats ? (
|
|
903
|
+
<span className="inline-flex items-center gap-1">
|
|
904
|
+
<LuLoaderCircle className="h-3 w-3 animate-spin" />
|
|
905
|
+
加载更多...
|
|
906
|
+
</span>
|
|
907
|
+
) : hasMoreChats ? (
|
|
908
|
+
'继续下滑加载更多'
|
|
909
|
+
) : (
|
|
910
|
+
`共 ${normalizedChats.length} 个会话`
|
|
911
|
+
)}
|
|
912
|
+
</div>
|
|
913
|
+
)}
|
|
914
|
+
</div>
|
|
915
|
+
)}
|
|
916
|
+
</div>
|
|
917
|
+
|
|
918
|
+
{(error || projectError) && (
|
|
919
|
+
<div
|
|
920
|
+
className="flex-shrink-0 border-t border-agent-destructive/40 bg-agent-destructive/10 px-3 py-2 text-[11px] text-agent-destructive"
|
|
921
|
+
role="alert"
|
|
922
|
+
>
|
|
923
|
+
{error ?? projectError}
|
|
924
|
+
</div>
|
|
925
|
+
)}
|
|
926
|
+
|
|
927
|
+
{/* ───── Footer: 右侧面板切换(终端 | 包槽位)+ 设置 ───── */}
|
|
928
|
+
<div className="flex-shrink-0 border-t border-agent-border/40 px-3 py-2">
|
|
929
|
+
{chatSlots.length === 0 ? (
|
|
930
|
+
<button
|
|
931
|
+
type="button"
|
|
932
|
+
onClick={() => onToggleRightPanel('terminal')}
|
|
933
|
+
className={[
|
|
934
|
+
'flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
|
|
935
|
+
rightPanel === 'terminal'
|
|
936
|
+
? 'bg-agent-foreground/10 font-medium text-agent-foreground'
|
|
937
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-foreground',
|
|
938
|
+
].join(' ')}
|
|
939
|
+
title={`${rightPanel === 'terminal' ? '关闭' : '打开'}终端面板 (${isMac ? '⌘T' : 'Ctrl+T'})`}
|
|
940
|
+
data-testid="sidebar-terminal"
|
|
941
|
+
>
|
|
942
|
+
<LuTerminal className="h-4 w-4" />
|
|
943
|
+
<span>终端</span>
|
|
944
|
+
<span className="ml-auto text-[10px] text-agent-muted-foreground/70">
|
|
945
|
+
{isMac ? '⌘T' : 'Ctrl+T'}
|
|
946
|
+
</span>
|
|
947
|
+
</button>
|
|
948
|
+
) : (
|
|
949
|
+
<div
|
|
950
|
+
role="group"
|
|
951
|
+
aria-label="右侧面板切换"
|
|
952
|
+
className="flex h-8 w-full items-center gap-0.5 rounded-full bg-agent-foreground/5 p-0.5"
|
|
953
|
+
>
|
|
954
|
+
<button
|
|
955
|
+
type="button"
|
|
956
|
+
onClick={() => onToggleRightPanel('terminal')}
|
|
957
|
+
className={[
|
|
958
|
+
'flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors',
|
|
959
|
+
rightPanel === 'terminal'
|
|
960
|
+
? 'bg-agent-foreground/10 text-agent-foreground'
|
|
961
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
962
|
+
].join(' ')}
|
|
963
|
+
title={`${rightPanel === 'terminal' ? '关闭' : '打开'}终端面板`}
|
|
964
|
+
data-testid="sidebar-terminal"
|
|
965
|
+
>
|
|
966
|
+
<LuTerminal className="h-3.5 w-3.5" />
|
|
967
|
+
<span>终端</span>
|
|
968
|
+
</button>
|
|
969
|
+
{chatSlots.map((slot) => (
|
|
970
|
+
<button
|
|
971
|
+
key={slot.slotId}
|
|
972
|
+
type="button"
|
|
973
|
+
onClick={() => onToggleRightPanel(slot.slotId)}
|
|
974
|
+
className={[
|
|
975
|
+
'flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full text-xs font-medium transition-colors',
|
|
976
|
+
rightPanel === slot.slotId
|
|
977
|
+
? 'bg-agent-foreground/10 text-agent-foreground'
|
|
978
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
979
|
+
].join(' ')}
|
|
980
|
+
title={`${rightPanel === slot.slotId ? '关闭' : '打开'}${slot.title}`}
|
|
981
|
+
data-testid={`sidebar-slot-${slot.slotId}`}
|
|
982
|
+
>
|
|
983
|
+
<slot.Icon className="h-3.5 w-3.5" />
|
|
984
|
+
<span>{slot.title}</span>
|
|
985
|
+
</button>
|
|
986
|
+
))}
|
|
987
|
+
</div>
|
|
988
|
+
)}
|
|
989
|
+
<button
|
|
990
|
+
type="button"
|
|
991
|
+
onClick={() => navigate('/settings')}
|
|
992
|
+
className={[
|
|
993
|
+
'mt-0.5 flex h-8 w-full items-center gap-2 rounded-full px-3 text-sm transition-colors',
|
|
994
|
+
onGeneralSettings
|
|
995
|
+
? 'bg-agent-foreground/10 font-medium text-agent-foreground'
|
|
996
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
997
|
+
].join(' ')}
|
|
998
|
+
title="设置"
|
|
999
|
+
data-testid="sidebar-llm-settings"
|
|
1000
|
+
>
|
|
1001
|
+
<LuSettings className="h-4 w-4" />
|
|
1002
|
+
<span>设置</span>
|
|
1003
|
+
</button>
|
|
1004
|
+
</div>
|
|
1005
|
+
</div>
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Re-export for callers that still import the type from here.
|
|
1010
|
+
export type { LocalChat, LocalChatAgent };
|