ideaco 1.1.5

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.
Files changed (159) hide show
  1. package/.dockerignore +33 -0
  2. package/.nvmrc +1 -0
  3. package/ARCHITECTURE.md +394 -0
  4. package/Dockerfile +50 -0
  5. package/LICENSE +29 -0
  6. package/README.md +206 -0
  7. package/bin/i18n.js +46 -0
  8. package/bin/ideaco.js +494 -0
  9. package/deploy.sh +15 -0
  10. package/docker-compose.yml +30 -0
  11. package/electron/main.cjs +986 -0
  12. package/electron/preload.cjs +14 -0
  13. package/electron/web-backends.cjs +854 -0
  14. package/jsconfig.json +8 -0
  15. package/next.config.mjs +34 -0
  16. package/package.json +134 -0
  17. package/postcss.config.mjs +6 -0
  18. package/public/demo/dashboard.png +0 -0
  19. package/public/demo/employee.png +0 -0
  20. package/public/demo/messages.png +0 -0
  21. package/public/demo/office.png +0 -0
  22. package/public/demo/requirement.png +0 -0
  23. package/public/logo.jpeg +0 -0
  24. package/public/logo.png +0 -0
  25. package/scripts/prepare-electron.js +67 -0
  26. package/scripts/release.js +76 -0
  27. package/src/app/api/agents/[agentId]/chat/route.js +70 -0
  28. package/src/app/api/agents/[agentId]/conversations/route.js +35 -0
  29. package/src/app/api/agents/[agentId]/route.js +106 -0
  30. package/src/app/api/avatar/route.js +104 -0
  31. package/src/app/api/browse-dir/route.js +44 -0
  32. package/src/app/api/chat/route.js +265 -0
  33. package/src/app/api/company/factory-reset/route.js +43 -0
  34. package/src/app/api/company/route.js +82 -0
  35. package/src/app/api/departments/[deptId]/agents/[agentId]/dismiss/route.js +19 -0
  36. package/src/app/api/departments/route.js +92 -0
  37. package/src/app/api/group-chat-loop/events/route.js +70 -0
  38. package/src/app/api/group-chat-loop/route.js +94 -0
  39. package/src/app/api/mailbox/route.js +100 -0
  40. package/src/app/api/messages/route.js +14 -0
  41. package/src/app/api/providers/[id]/configure/route.js +21 -0
  42. package/src/app/api/providers/[id]/refresh-cookie/route.js +38 -0
  43. package/src/app/api/providers/[id]/test-cookie/route.js +28 -0
  44. package/src/app/api/providers/route.js +11 -0
  45. package/src/app/api/requirements/route.js +242 -0
  46. package/src/app/api/secretary/route.js +65 -0
  47. package/src/app/api/system/cli-backends/route.js +91 -0
  48. package/src/app/api/system/cron/route.js +110 -0
  49. package/src/app/api/system/knowledge/route.js +104 -0
  50. package/src/app/api/system/plugins/route.js +40 -0
  51. package/src/app/api/system/skills/route.js +46 -0
  52. package/src/app/api/system/status/route.js +46 -0
  53. package/src/app/api/talent-market/[profileId]/recall/route.js +22 -0
  54. package/src/app/api/talent-market/[profileId]/route.js +17 -0
  55. package/src/app/api/talent-market/route.js +26 -0
  56. package/src/app/api/teams/route.js +773 -0
  57. package/src/app/api/ws-files/[departmentId]/file/route.js +27 -0
  58. package/src/app/api/ws-files/[departmentId]/files/route.js +22 -0
  59. package/src/app/globals.css +130 -0
  60. package/src/app/layout.jsx +40 -0
  61. package/src/app/page.jsx +97 -0
  62. package/src/components/AgentChatModal.jsx +164 -0
  63. package/src/components/AgentDetailModal.jsx +425 -0
  64. package/src/components/AgentSpyModal.jsx +481 -0
  65. package/src/components/AvatarGrid.jsx +29 -0
  66. package/src/components/BossProfileModal.jsx +162 -0
  67. package/src/components/CachedAvatar.jsx +77 -0
  68. package/src/components/ChatPanel.jsx +219 -0
  69. package/src/components/ChatShared.jsx +255 -0
  70. package/src/components/DepartmentDetail.jsx +842 -0
  71. package/src/components/DepartmentView.jsx +367 -0
  72. package/src/components/FileReference.jsx +260 -0
  73. package/src/components/FilesView.jsx +465 -0
  74. package/src/components/GroupChatView.jsx +799 -0
  75. package/src/components/Mailbox.jsx +926 -0
  76. package/src/components/MessagesView.jsx +112 -0
  77. package/src/components/OnboardingGuide.jsx +209 -0
  78. package/src/components/OrgTree.jsx +151 -0
  79. package/src/components/Overview.jsx +391 -0
  80. package/src/components/PixelOffice.jsx +2281 -0
  81. package/src/components/ProviderGrid.jsx +551 -0
  82. package/src/components/ProvidersBoard.jsx +16 -0
  83. package/src/components/RequirementDetail.jsx +1279 -0
  84. package/src/components/RequirementsBoard.jsx +187 -0
  85. package/src/components/SecretarySettings.jsx +295 -0
  86. package/src/components/SetupWizard.jsx +388 -0
  87. package/src/components/Sidebar.jsx +169 -0
  88. package/src/components/SystemMonitor.jsx +808 -0
  89. package/src/components/TalentMarket.jsx +183 -0
  90. package/src/components/TeamDetail.jsx +697 -0
  91. package/src/core/agent/base-agent.js +104 -0
  92. package/src/core/agent/chat-store.js +602 -0
  93. package/src/core/agent/cli-agent/backends/claude-code/README.md +52 -0
  94. package/src/core/agent/cli-agent/backends/claude-code/config.js +27 -0
  95. package/src/core/agent/cli-agent/backends/codebuddy/README.md +236 -0
  96. package/src/core/agent/cli-agent/backends/codebuddy/config.js +27 -0
  97. package/src/core/agent/cli-agent/backends/codex/README.md +51 -0
  98. package/src/core/agent/cli-agent/backends/codex/config.js +27 -0
  99. package/src/core/agent/cli-agent/backends/index.js +27 -0
  100. package/src/core/agent/cli-agent/backends/registry.js +580 -0
  101. package/src/core/agent/cli-agent/index.js +154 -0
  102. package/src/core/agent/index.js +60 -0
  103. package/src/core/agent/llm-agent/client.js +320 -0
  104. package/src/core/agent/llm-agent/index.js +97 -0
  105. package/src/core/agent/message-bus.js +211 -0
  106. package/src/core/agent/session.js +608 -0
  107. package/src/core/agent/tools.js +596 -0
  108. package/src/core/agent/web-agent/backends/base-backend.js +180 -0
  109. package/src/core/agent/web-agent/backends/chatgpt/client.js +146 -0
  110. package/src/core/agent/web-agent/backends/chatgpt/config.js +148 -0
  111. package/src/core/agent/web-agent/backends/chatgpt/dom-scripts.js +303 -0
  112. package/src/core/agent/web-agent/backends/index.js +91 -0
  113. package/src/core/agent/web-agent/index.js +278 -0
  114. package/src/core/agent/web-agent/web-client.js +407 -0
  115. package/src/core/employee/base-employee.js +1088 -0
  116. package/src/core/employee/index.js +35 -0
  117. package/src/core/employee/knowledge.js +327 -0
  118. package/src/core/employee/lifecycle.js +990 -0
  119. package/src/core/employee/memory/index.js +642 -0
  120. package/src/core/employee/memory/store.js +143 -0
  121. package/src/core/employee/performance.js +224 -0
  122. package/src/core/employee/secretary.js +625 -0
  123. package/src/core/employee/skills.js +398 -0
  124. package/src/core/index.js +38 -0
  125. package/src/core/organization/company.js +2600 -0
  126. package/src/core/organization/department.js +737 -0
  127. package/src/core/organization/group-chat-loop.js +264 -0
  128. package/src/core/organization/index.js +8 -0
  129. package/src/core/organization/persistence.js +111 -0
  130. package/src/core/organization/team.js +267 -0
  131. package/src/core/organization/workforce/hr.js +377 -0
  132. package/src/core/organization/workforce/providers.js +468 -0
  133. package/src/core/organization/workforce/role-archetypes.js +805 -0
  134. package/src/core/organization/workforce/talent-market.js +205 -0
  135. package/src/core/prompts.js +532 -0
  136. package/src/core/requirement.js +1789 -0
  137. package/src/core/system/audit.js +483 -0
  138. package/src/core/system/cron.js +449 -0
  139. package/src/core/system/index.js +7 -0
  140. package/src/core/system/plugin.js +2183 -0
  141. package/src/core/utils/json-parse.js +188 -0
  142. package/src/core/workspace.js +239 -0
  143. package/src/lib/api-i18n.js +211 -0
  144. package/src/lib/avatar.js +268 -0
  145. package/src/lib/client-store.js +1025 -0
  146. package/src/lib/config-validator.js +483 -0
  147. package/src/lib/format-time.js +22 -0
  148. package/src/lib/hooks.js +414 -0
  149. package/src/lib/i18n.js +134 -0
  150. package/src/lib/paths.js +23 -0
  151. package/src/lib/store.js +72 -0
  152. package/src/locales/de.js +393 -0
  153. package/src/locales/en.js +1054 -0
  154. package/src/locales/es.js +393 -0
  155. package/src/locales/fr.js +393 -0
  156. package/src/locales/ja.js +501 -0
  157. package/src/locales/ko.js +513 -0
  158. package/src/locales/zh.js +828 -0
  159. package/tailwind.config.mjs +11 -0
@@ -0,0 +1,1279 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
4
+ import { useStore } from '@/lib/client-store';
5
+ import { useI18n } from '@/lib/i18n';
6
+ import GroupChatView from './GroupChatView';
7
+ import AgentDetailModal from './AgentDetailModal';
8
+ import CachedAvatar from './CachedAvatar';
9
+ import FilesView from './FilesView';
10
+ import PixelOffice from './PixelOffice';
11
+
12
+ import { useRouter } from 'next/navigation';
13
+
14
+
15
+
16
+ /**
17
+ * Requirement detail page
18
+ * Displays: requirement info, workflow DAG, group chat messages, output results
19
+ */
20
+ export default function RequirementDetail({ requirementId, onClose }) {
21
+ const { t } = useI18n();
22
+ const { fetchRequirementDetail, requirementDetail, clearRequirementDetail, fetchWorkspaceFile, navigateBack, activeRequirementId, deleteRequirement, restartRequirement, sendGroupChatMessage, company } = useStore();
23
+ const reqId = requirementId || activeRequirementId;
24
+ const isPage = !onClose; // If no onClose is passed, it is standalone page mode
25
+ const [activeTab, setActiveTab] = useState('workflow'); // workflow | files
26
+ const chatEndRef = useRef(null);
27
+ const pollRef = useRef(null);
28
+ const [previewFile, setPreviewFile] = useState(null); // { path, content, loading }
29
+
30
+ // 群员交互:查看卡片信息 + 偷看心流
31
+ const [selectedMemberAgentId, setSelectedMemberAgentId] = useState(null);
32
+ const [peekFlowAgentId, setPeekFlowAgentId] = useState(null);
33
+ const [peekFlowData, setPeekFlowData] = useState(null);
34
+ const [peekFlowMsgs, setPeekFlowMsgs] = useState([]);
35
+ const [peekFlowThoughtMsgs, setPeekFlowThoughtMsgs] = useState([]); // 内心独白消息(monologue 类型)
36
+ const [peekFlowHistory, setPeekFlowHistory] = useState([]);
37
+ const [peekFlowLoading, setPeekFlowLoading] = useState(false);
38
+ const [peekFlowTab, setPeekFlowTab] = useState('thoughts');
39
+
40
+ // Save reqId to ref to avoid reading stale value in closure
41
+ const reqIdRef = useRef(reqId);
42
+ reqIdRef.current = reqId;
43
+
44
+ useEffect(() => {
45
+ if (!reqId) return;
46
+ fetchRequirementDetail(reqId);
47
+ // Speed up polling for executing requirements (2s)
48
+ pollRef.current = setInterval(() => {
49
+ fetchRequirementDetail(reqIdRef.current);
50
+ }, 2000);
51
+ return () => {
52
+ if (pollRef.current) {
53
+ clearInterval(pollRef.current);
54
+ pollRef.current = null;
55
+ }
56
+ };
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [reqId]);
59
+
60
+ // Clear all states on component unmount
61
+ useEffect(() => {
62
+ return () => {
63
+ clearRequirementDetail();
64
+ if (pollRef.current) {
65
+ clearInterval(pollRef.current);
66
+ pollRef.current = null;
67
+ }
68
+ };
69
+ }, []);
70
+
71
+ // Stop polling completed requirements or reduce polling frequency
72
+ useEffect(() => {
73
+ if (requirementDetail && (requirementDetail.status === 'completed' || requirementDetail.status === 'failed' || requirementDetail.status === 'pending_approval')) {
74
+ if (pollRef.current) {
75
+ clearInterval(pollRef.current);
76
+ // Reduce to 10s polling after completion (keep updated without too many resources)
77
+ pollRef.current = setInterval(() => {
78
+ fetchRequirementDetail(reqId);
79
+ }, 10000);
80
+ }
81
+ }
82
+ }, [requirementDetail?.status]);
83
+
84
+ useEffect(() => {
85
+ if (activeTab === 'chat') {
86
+ chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
87
+ }
88
+ }, [activeTab, requirementDetail?.groupChat?.length]);
89
+
90
+ // File preview loading
91
+ const loadFilePreview = useCallback(async (filePath) => {
92
+ if (!requirementDetail?.departmentId) return;
93
+ setPreviewFile({ path: filePath, content: null, loading: true });
94
+ try {
95
+ const result = await fetchWorkspaceFile(requirementDetail.departmentId, filePath);
96
+ // Check if API returned an error (e.g. permission denied)
97
+ if (result?.error) {
98
+ setPreviewFile({
99
+ path: filePath,
100
+ content: `⚠ ${result.error}`,
101
+ loading: false,
102
+ });
103
+ return;
104
+ }
105
+ const fileContent = result?.content ?? result ?? null;
106
+ setPreviewFile({
107
+ path: filePath,
108
+ content: fileContent != null ? String(fileContent) : t('reqDetail.files.noContent'),
109
+ loading: false,
110
+ });
111
+ } catch {
112
+ setPreviewFile({ path: filePath, content: t('reqDetail.files.readFailed'), loading: false });
113
+ }
114
+ }, [requirementDetail?.departmentId]);
115
+
116
+ // 偷看员工心流
117
+ const peekMemberFlow = useCallback(async (agentId) => {
118
+ setPeekFlowAgentId(agentId);
119
+ setPeekFlowLoading(true);
120
+ setPeekFlowTab('thoughts');
121
+ try {
122
+ const [currentRes, historyRes, flowRes, thoughtRes] = await Promise.all([
123
+ fetch(`/api/group-chat-loop?agentId=${agentId}&groupId=${reqId}`),
124
+ fetch(`/api/group-chat-loop?agentId=${agentId}&groupId=${reqId}&history=1`),
125
+ fetch(`/api/group-chat-loop?agentId=${agentId}&groupId=${reqId}&flowMessages=1`),
126
+ fetch(`/api/group-chat-loop?agentId=${agentId}&groupId=${reqId}&monologueMessages=1`),
127
+ ]);
128
+ const currentData = await currentRes.json();
129
+ const historyData = await historyRes.json();
130
+ const flowData = await flowRes.json();
131
+ const thoughtData = await thoughtRes.json();
132
+ setPeekFlowData(currentData.data);
133
+ setPeekFlowHistory(historyData.data || []);
134
+ setPeekFlowMsgs(flowData.data || []);
135
+ setPeekFlowThoughtMsgs(thoughtData.data || []);
136
+ } catch (err) {
137
+ console.error('Failed to peek flow:', err);
138
+ } finally {
139
+ setPeekFlowLoading(false);
140
+ }
141
+ }, [reqId]);
142
+
143
+ // 自动刷新心流
144
+ useEffect(() => {
145
+ if (!peekFlowAgentId || !reqId) return;
146
+ const timer = setInterval(async () => {
147
+ try {
148
+ const [res, flowRes, thoughtRes] = await Promise.all([
149
+ fetch(`/api/group-chat-loop?agentId=${peekFlowAgentId}&groupId=${reqId}`),
150
+ fetch(`/api/group-chat-loop?agentId=${peekFlowAgentId}&groupId=${reqId}&flowMessages=1`),
151
+ fetch(`/api/group-chat-loop?agentId=${peekFlowAgentId}&groupId=${reqId}&monologueMessages=1`),
152
+ ]);
153
+ const data = await res.json();
154
+ const flowData = await flowRes.json();
155
+ const thoughtData = await thoughtRes.json();
156
+ if (data.data) setPeekFlowData(data.data);
157
+ if (flowData.data) setPeekFlowMsgs(flowData.data);
158
+ if (thoughtData.data) setPeekFlowThoughtMsgs(thoughtData.data);
159
+ } catch {}
160
+ }, 3000);
161
+ return () => clearInterval(timer);
162
+ }, [peekFlowAgentId, reqId]);
163
+
164
+ const handleClose = onClose || navigateBack;
165
+
166
+ if (!requirementDetail) {
167
+ return isPage ? (
168
+ <div className="flex items-center justify-center h-full">
169
+ <div className="card p-8">
170
+ <span className="animate-pulse text-[var(--muted)]">{t('common.loading')}</span>
171
+ </div>
172
+ </div>
173
+ ) : (
174
+ <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center !m-0">
175
+ <div className="card p-8">
176
+ <span className="animate-pulse text-[var(--muted)]">{t('common.loading')}</span>
177
+ </div>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ const req = requirementDetail;
183
+ const statusConfig = {
184
+ pending: { label: t('reqDetail.status.pending'), color: 'text-gray-400', bg: 'bg-gray-900/30' },
185
+ planning: { label: t('reqDetail.status.planning'), color: 'text-blue-400', bg: 'bg-blue-900/30' },
186
+ in_progress: { label: t('reqDetail.status.in_progress'), color: 'text-yellow-400', bg: 'bg-yellow-900/30' },
187
+ pending_approval: { label: t('reqDetail.status.pending_approval'), color: 'text-orange-400', bg: 'bg-orange-900/30' },
188
+ completed: { label: t('reqDetail.status.completed'), color: 'text-green-400', bg: 'bg-green-900/30' },
189
+ failed: { label: t('reqDetail.status.failed'), color: 'text-red-400', bg: 'bg-red-900/30' },
190
+ };
191
+ const st = statusConfig[req.status] || statusConfig.pending;
192
+
193
+ // Page mode: full-screen standalone page
194
+ if (isPage) {
195
+ return (
196
+ <div className="h-full flex flex-col animate-fade-in">
197
+ {/* Header navigation bar */}
198
+ <div className="px-6 py-4 border-b border-white/[0.06] bg-[var(--card)] flex items-start justify-between shrink-0">
199
+ <div className="flex items-center gap-4 flex-1 min-w-0">
200
+ <button
201
+ onClick={handleClose}
202
+ className="text-[var(--muted)] hover:text-white text-sm flex items-center gap-1 shrink-0 transition-colors hover:bg-white/5 px-2 py-1 rounded-lg"
203
+ >
204
+ ← {t('reqDetail.backShort')}
205
+ </button>
206
+ <div className="w-px h-8 bg-white/[0.08]" />
207
+ <div className="flex-1 min-w-0">
208
+ <div className="flex items-center gap-3">
209
+ <h1 className="text-xl font-bold truncate">{req.title}</h1>
210
+ <span className={`text-xs px-2 py-0.5 rounded-full ${st.bg} ${st.color}`}>
211
+ {st.label}
212
+ </span>
213
+ {req.status === 'in_progress' && (
214
+ <span className="animate-pulse text-yellow-400 text-xs">{t('reqDetail.executingShort')}</span>
215
+ )}
216
+ </div>
217
+ <div className="flex items-center gap-4 mt-1 text-xs text-[var(--muted)] flex-wrap">
218
+ <span className="whitespace-nowrap">🏢 {req.departmentName}</span>
219
+ <span className="whitespace-nowrap">📅 {new Date(req.createdAt).toLocaleString()}</span>
220
+ <span className="truncate max-w-md">{req.description}</span>
221
+ {req.summary && (
222
+ <>
223
+ <span className="whitespace-nowrap">{t('reqDetail.summary.tasks', { n: req.summary.successTasks, total: req.summary.totalTasks })}</span>
224
+ <span className="whitespace-nowrap">{t('reqDetail.summary.duration', { n: Math.round((req.summary.totalDuration || 0) / 1000) })}</span>
225
+ </>
226
+ )}
227
+ </div>
228
+ </div>
229
+ </div>
230
+ <div className="flex items-center gap-2 shrink-0 ml-4 electron-no-drag relative z-[10000]">
231
+ <button
232
+ onClick={() => restartRequirement(req.id)}
233
+ className="text-sm px-4 py-2 rounded-lg bg-blue-600/15 hover:bg-blue-600/25 text-blue-400 border border-blue-500/20 transition-colors flex items-center gap-1.5 cursor-pointer min-h-[36px] select-none"
234
+ >
235
+ {t('reqDetail.live.restart')}
236
+ </button>
237
+ <button
238
+ onClick={() => { if (confirm(t('reqDetail.live.confirmDelete'))) deleteRequirement(req.id); }}
239
+ className="text-sm px-4 py-2 rounded-lg bg-red-600/15 hover:bg-red-600/25 text-red-400 border border-red-500/20 transition-colors flex items-center gap-1.5 cursor-pointer min-h-[36px] select-none"
240
+ >
241
+ {t('reqDetail.live.deleteReq')}
242
+ </button>
243
+ </div>
244
+ </div>
245
+
246
+ {/* 左右布局主体 */}
247
+ <div className="flex-1 min-h-0 flex">
248
+ {/* 左侧:群聊面板 */}
249
+ <div className="w-[380px] shrink-0 border-r border-white/[0.06] flex flex-col bg-[var(--background)]">
250
+ <div className="px-4 py-2.5 border-b border-white/[0.06] bg-[var(--card)] flex items-center justify-between">
251
+ <span className="text-sm font-medium">{t('reqDetail.tabs.chat')}</span>
252
+ <span className="text-[10px] bg-white/10 px-1.5 py-0.5 rounded-full text-[var(--muted)]">{(req.groupChat || []).length}</span>
253
+ </div>
254
+ {(() => {
255
+ const chatAgentMap = {};
256
+ if (company?.departments) {
257
+ for (const dept of company.departments) {
258
+ for (const agent of (dept.members || dept.agents || [])) {
259
+ chatAgentMap[agent.id] = agent.name;
260
+ }
261
+ }
262
+ }
263
+ const leaderMsg = (req.groupChat || []).find(m => m.from?.id !== 'boss' && m.from?.role !== 'system' && m.type !== 'system');
264
+ const chatLeaderInfo = leaderMsg ? { name: leaderMsg.from?.name, avatar: leaderMsg.from?.avatar } : null;
265
+ return (
266
+ <GroupChatView
267
+ groupChat={req.groupChat || []}
268
+ agentMap={chatAgentMap}
269
+ bossAvatar={company?.bossAvatar}
270
+ bossName={company?.boss || 'Boss'}
271
+ requirementId={req.id}
272
+ onSendMessage={sendGroupChatMessage}
273
+ fetchDetail={fetchRequirementDetail}
274
+ leaderInfo={chatLeaderInfo}
275
+ chatEndRef={chatEndRef}
276
+ embedded
277
+ inputPlaceholder={req.status === 'pending_approval' ? t('reqDetail.approvalHint') : undefined}
278
+ />
279
+ );
280
+ })()}
281
+ </div>
282
+
283
+ {/* 右侧:群员列表 + Tab bar + Content */}
284
+ <div className="flex-1 min-w-0 flex flex-col">
285
+ {/* 群员列表 + 流程卡点 */}
286
+ <MembersAndBlockingPanel members={req.members} blockingInfo={req.blockingInfo} workflow={req.workflow} status={req.status} onPeekFlow={peekMemberFlow} onViewAgent={setSelectedMemberAgentId} />
287
+
288
+ {/* Tab bar (不含 chat,因为群聊已在左侧) */}
289
+ <div className="flex border-b border-white/[0.06] shrink-0 px-6 bg-[var(--card)]">
290
+ {[
291
+ { id: 'workflow', label: t('reqDetail.tabs.workflow'), badge: req.workflow?.nodes?.length },
292
+ { id: 'files', label: t('reqDetail.tabs.files'), badge: new Set((req.liveStatus?.recentFileChanges || []).filter(f => f.filePath).map(f => f.filePath.replace(/^\.[\/\\]/, ''))).size },
293
+ { id: 'office', label: t('reqDetail.tabs.office') },
294
+ ].map(tab => (
295
+ <button
296
+ key={tab.id}
297
+ onClick={() => setActiveTab(tab.id)}
298
+ className={`px-5 py-3 text-sm font-medium transition-all border-b-2 ${
299
+ activeTab === tab.id
300
+ ? 'border-[var(--accent)] text-[var(--accent)]'
301
+ : 'border-transparent text-[var(--muted)] hover:text-white'
302
+ }`}
303
+ >
304
+ {tab.label}
305
+ {tab.badge > 0 && (
306
+ <span className="ml-1.5 text-[10px] bg-white/10 px-1.5 py-0.5 rounded-full">{tab.badge}</span>
307
+ )}
308
+ </button>
309
+ ))}
310
+ </div>
311
+
312
+ {/* Content area */}
313
+ <div className={`flex-1 min-h-0 flex flex-col pb-6 ${activeTab === 'files' || activeTab === 'office' ? 'overflow-hidden' : 'overflow-auto'}`}>
314
+ {activeTab === 'workflow' && (
315
+ <WorkflowView workflow={req.workflow} liveStatus={req.liveStatus} members={req.members} />
316
+ )}
317
+ {activeTab === 'files' && (
318
+ <div className="flex-1 min-h-0">
319
+ <FilesView
320
+ fileChanges={req.liveStatus?.recentFileChanges || []}
321
+ departmentId={req.departmentId}
322
+ previewFile={previewFile}
323
+ onPreview={loadFilePreview}
324
+ onClosePreview={() => setPreviewFile(null)}
325
+ />
326
+ </div>
327
+ )}
328
+ {activeTab === 'office' && (
329
+ <div className="flex-1 min-h-0">
330
+ <PixelOffice embedded groupChat={req.groupChat} members={req.members} />
331
+ </div>
332
+ )}
333
+ </div>
334
+ </div>
335
+ </div>
336
+
337
+ {/* Agent 卡片弹窗 */}
338
+ {selectedMemberAgentId && (
339
+ <AgentDetailModal agentId={selectedMemberAgentId} onClose={() => setSelectedMemberAgentId(null)} />
340
+ )}
341
+
342
+ {/* 心流偷看弹窗 */}
343
+ {peekFlowAgentId && (
344
+ <FlowPeekModal
345
+ agentId={peekFlowAgentId}
346
+ agentName={req.members?.find(m => m.id === peekFlowAgentId)?.name || peekFlowAgentId}
347
+ loading={peekFlowLoading}
348
+ tab={peekFlowTab}
349
+ onTabChange={setPeekFlowTab}
350
+ flowMsgs={peekFlowMsgs}
351
+ monologueData={peekFlowData}
352
+ monologueThoughtMsgs={peekFlowThoughtMsgs}
353
+ history={peekFlowHistory}
354
+ onClose={() => setPeekFlowAgentId(null)}
355
+ />
356
+ )}
357
+ </div>
358
+ );
359
+ }
360
+
361
+ // Modal mode (backward compatible, but no longer used)
362
+ return (
363
+ <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center !m-0" onClick={handleClose}>
364
+ <div
365
+ className="bg-[var(--card)] border border-[var(--border)] rounded-2xl max-w-5xl w-full mx-4 max-h-[90vh] min-h-[60vh] flex flex-col"
366
+ onClick={e => e.stopPropagation()}
367
+ >
368
+ {/* Header */}
369
+ <div className="px-6 py-4 border-b border-white/[0.06] flex items-start justify-between shrink-0">
370
+ <div className="flex-1 min-w-0">
371
+ <div className="flex items-center gap-3">
372
+ <h2 className="text-lg font-bold truncate">{req.title}</h2>
373
+ <span className={`text-xs px-2 py-0.5 rounded-full ${st.bg} ${st.color}`}>
374
+ {st.label}
375
+ </span>
376
+ {req.status === 'in_progress' && (
377
+ <span className="animate-pulse text-yellow-400 text-xs">{t('reqDetail.executingShort')}</span>
378
+ )}
379
+ </div>
380
+ <p className="text-sm text-[var(--muted)] mt-1 line-clamp-2">{req.description}</p>
381
+ <div className="flex items-center gap-4 mt-2 text-xs text-[var(--muted)] flex-wrap">
382
+ <span className="whitespace-nowrap">🏢 {req.departmentName}</span>
383
+ <span className="whitespace-nowrap">📅 {new Date(req.createdAt).toLocaleString()}</span>
384
+ {req.summary && (
385
+ <>
386
+ <span className="whitespace-nowrap">✅ {req.summary.successTasks}/{req.summary.totalTasks} {t('reqDetail.summary.tasks', { n: req.summary.successTasks, total: req.summary.totalTasks }).replace(/✅ \d+\/\d+ /, '')}</span>
387
+ <span className="whitespace-nowrap">⏱️ {Math.round((req.summary.totalDuration || 0) / 1000)}s</span>
388
+ </>
389
+ )}
390
+ </div>
391
+ </div>
392
+ <button onClick={handleClose} className="text-[var(--muted)] hover:text-white text-xl ml-4 shrink-0">✕</button>
393
+ </div>
394
+
395
+ {/* 群员列表 + 流程卡点 */}
396
+ <MembersAndBlockingPanel members={req.members} blockingInfo={req.blockingInfo} workflow={req.workflow} status={req.status} onPeekFlow={peekMemberFlow} onViewAgent={setSelectedMemberAgentId} />
397
+
398
+ {/* Tab bar */}
399
+ <div className="flex border-b border-white/[0.06] shrink-0 px-6">
400
+ {[
401
+ { id: 'workflow', label: t('reqDetail.tabs.workflow'), badge: req.workflow?.nodes?.length },
402
+ { id: 'chat', label: t('reqDetail.tabs.chat'), badge: req.groupChat?.length },
403
+ { id: 'files', label: t('reqDetail.tabs.files'), badge: new Set((req.liveStatus?.recentFileChanges || []).filter(f => f.filePath).map(f => f.filePath.replace(/^\.[\/\\]/, ''))).size },
404
+ { id: 'office', label: t('reqDetail.tabs.office') },
405
+ ].map(tab => (
406
+ <button
407
+ key={tab.id}
408
+ onClick={() => setActiveTab(tab.id)}
409
+ className={`px-5 py-3 text-sm font-medium transition-all border-b-2 ${
410
+ activeTab === tab.id
411
+ ? 'border-[var(--accent)] text-[var(--accent)]'
412
+ : 'border-transparent text-[var(--muted)] hover:text-white'
413
+ }`}
414
+ >
415
+ {tab.label}
416
+ {tab.badge > 0 && (
417
+ <span className="ml-1.5 text-[10px] bg-white/10 px-1.5 py-0.5 rounded-full">{tab.badge}</span>
418
+ )}
419
+ </button>
420
+ ))}
421
+ </div>
422
+
423
+ {/* Content area */}
424
+ <div className={`flex-1 min-h-0 flex flex-col pb-6 ${activeTab === 'files' || activeTab === 'office' ? 'overflow-hidden' : 'overflow-auto'}`} style={{ minHeight: activeTab === 'files' || activeTab === 'office' ? '400px' : undefined }}>
425
+ {activeTab === 'workflow' && (
426
+ <WorkflowView workflow={req.workflow} liveStatus={req.liveStatus} members={req.members} />
427
+ )}
428
+ {activeTab === 'chat' && (() => {
429
+ const chatAgentMap = {};
430
+ if (company?.departments) {
431
+ for (const dept of company.departments) {
432
+ for (const agent of (dept.members || dept.agents || [])) {
433
+ chatAgentMap[agent.id] = agent.name;
434
+ }
435
+ }
436
+ }
437
+ const leaderMsg = (req.groupChat || []).find(m => m.from?.id !== 'boss' && m.from?.role !== 'system' && m.type !== 'system');
438
+ const chatLeaderInfo = leaderMsg ? { name: leaderMsg.from?.name, avatar: leaderMsg.from?.avatar } : null;
439
+ return (
440
+ <GroupChatView
441
+ groupChat={req.groupChat || []}
442
+ agentMap={chatAgentMap}
443
+ bossAvatar={company?.bossAvatar}
444
+ bossName={company?.boss || 'Boss'}
445
+ requirementId={req.id}
446
+ onSendMessage={sendGroupChatMessage}
447
+ fetchDetail={fetchRequirementDetail}
448
+ leaderInfo={chatLeaderInfo}
449
+ chatEndRef={chatEndRef}
450
+ embedded
451
+ inputPlaceholder={req.status === 'pending_approval' ? t('reqDetail.approvalHint') : undefined}
452
+ />
453
+ );
454
+ })()}
455
+ {activeTab === 'files' && (
456
+ <div className="flex-1 min-h-0">
457
+ <FilesView
458
+ fileChanges={req.liveStatus?.recentFileChanges || []}
459
+ departmentId={req.departmentId}
460
+ previewFile={previewFile}
461
+ onPreview={loadFilePreview}
462
+ onClosePreview={() => setPreviewFile(null)}
463
+ />
464
+ </div>
465
+ )}
466
+ {activeTab === 'office' && (
467
+ <div className="flex-1 min-h-0">
468
+ <PixelOffice embedded groupChat={req.groupChat} members={req.members} />
469
+ </div>
470
+ )}
471
+ </div>
472
+
473
+ {/* Agent 卡片弹窗 */}
474
+ {selectedMemberAgentId && (
475
+ <AgentDetailModal agentId={selectedMemberAgentId} onClose={() => setSelectedMemberAgentId(null)} />
476
+ )}
477
+
478
+ {/* 心流偷看弹窗 */}
479
+ {peekFlowAgentId && (
480
+ <FlowPeekModal
481
+ agentId={peekFlowAgentId}
482
+ agentName={req.members?.find(m => m.id === peekFlowAgentId)?.name || peekFlowAgentId}
483
+ loading={peekFlowLoading}
484
+ tab={peekFlowTab}
485
+ onTabChange={setPeekFlowTab}
486
+ flowMsgs={peekFlowMsgs}
487
+ monologueData={peekFlowData}
488
+ monologueThoughtMsgs={peekFlowThoughtMsgs}
489
+ history={peekFlowHistory}
490
+ onClose={() => setPeekFlowAgentId(null)}
491
+ />
492
+ )}
493
+ </div>
494
+ </div>
495
+ );
496
+ }
497
+
498
+
499
+ /**
500
+ * Workflow visualization - SVG flowchart + div cards (foreignObject)
501
+ * Multi-arrow merging: multiple incoming edges merge into one vertical line then connect to target
502
+ */
503
+ function WorkflowView({ workflow, liveStatus, members }) {
504
+ const { t } = useI18n();
505
+ const containerRef = useRef(null);
506
+ const svgRef = useRef(null);
507
+ const measureRef = useRef(null);
508
+ const [containerWidth, setContainerWidth] = useState(800);
509
+ const [hoveredNode, setHoveredNode] = useState(null); // { node, rect }
510
+ const [measuredHeights, setMeasuredHeights] = useState({}); // nodeId -> height
511
+ const [measureTick, setMeasureTick] = useState(0); // Used to trigger re-measurement
512
+
513
+ // Watch container width
514
+ useEffect(() => {
515
+ if (!containerRef.current) return;
516
+ const obs = new ResizeObserver(entries => {
517
+ const w = entries[0]?.contentRect?.width;
518
+ if (w) setContainerWidth(w);
519
+ });
520
+ obs.observe(containerRef.current);
521
+ return () => obs.disconnect();
522
+ }, []);
523
+
524
+ // MutationObserver: watch measurement container DOM changes, auto re-measure with debounce
525
+ useEffect(() => {
526
+ if (!measureRef.current) return;
527
+ let timer = null;
528
+ const obs = new MutationObserver(() => {
529
+ clearTimeout(timer);
530
+ timer = setTimeout(() => setMeasureTick(t => t + 1), 100);
531
+ });
532
+ obs.observe(measureRef.current, { childList: true, subtree: true, characterData: true, attributes: true });
533
+ return () => { obs.disconnect(); clearTimeout(timer); };
534
+ }, [workflow]);
535
+
536
+ // Measure actual heights of all cards
537
+ useEffect(() => {
538
+ if (!measureRef.current) return;
539
+ const cards = measureRef.current.querySelectorAll('[data-node-id]');
540
+ const heights = {};
541
+ cards.forEach(card => {
542
+ const id = card.getAttribute('data-node-id');
543
+ if (id) {
544
+ // scrollHeight gets actual content height
545
+ heights[id] = Math.max(card.scrollHeight, 60); // minimum 60px
546
+ }
547
+ });
548
+ setMeasuredHeights(prev => {
549
+ // Only update when actually changed to avoid infinite loop
550
+ const changed = Object.keys(heights).some(k => prev[k] !== heights[k]) ||
551
+ Object.keys(heights).length !== Object.keys(prev).length;
552
+ return changed ? heights : prev;
553
+ });
554
+ }, [workflow, liveStatus, containerWidth, measureTick]);
555
+
556
+ // Topological layering + layout calculation (based on measured heights)
557
+ const layout = useMemo(() => {
558
+ if (!workflow?.nodes?.length) return null;
559
+ const nodes = workflow.nodes;
560
+
561
+ // Topological layering
562
+ const levels = [];
563
+ const placed = new Set();
564
+ let remaining = [...nodes];
565
+ while (remaining.length > 0) {
566
+ const level = remaining.filter(n => n.dependencies.every(d => placed.has(d)));
567
+ if (level.length === 0) { levels.push(remaining); break; }
568
+ levels.push(level);
569
+ level.forEach(n => placed.add(n.id));
570
+ remaining = remaining.filter(n => !placed.has(n.id));
571
+ }
572
+
573
+ // Layout parameters
574
+ const nodeW = 280;
575
+ const defaultH = 90; // Default height (initial value before measurement)
576
+ const padding = 8; // Extra padding
577
+ const gapX = 32, gapY = 80;
578
+ const padX = 40, padY = 40;
579
+
580
+ // Calculate the minimum width needed by the widest layer
581
+ let maxLayerW = 0;
582
+ levels.forEach((level) => {
583
+ const layerW = level.length * nodeW + (level.length - 1) * gapX;
584
+ if (layerW > maxLayerW) maxLayerW = layerW;
585
+ });
586
+
587
+ // Effective width: expand to fit all parallel nodes, allowing horizontal scroll
588
+ const effectiveW = Math.max(containerWidth - padX * 2, 600, maxLayerW);
589
+
590
+ // Calculate node positions per layer (centered, height from measurement)
591
+ const nodePositions = {};
592
+ let cumulativeY = padY;
593
+ levels.forEach((level, li) => {
594
+ // Max height of this layer = tallest measured card height in this layer
595
+ const layerH = Math.max(...level.map(n => (measuredHeights[n.id] || defaultH) + padding));
596
+ const totalW = level.length * nodeW + (level.length - 1) * gapX;
597
+ const startX = padX + (effectiveW - totalW) / 2;
598
+ level.forEach((node, ni) => {
599
+ const h = (measuredHeights[node.id] || defaultH) + padding;
600
+ nodePositions[node.id] = {
601
+ x: Math.max(padX, startX + ni * (nodeW + gapX)),
602
+ y: cumulativeY,
603
+ w: nodeW,
604
+ h,
605
+ node,
606
+ level: li,
607
+ };
608
+ });
609
+ cumulativeY += layerH + gapY;
610
+ });
611
+
612
+ const totalHeight = cumulativeY - gapY + padY;
613
+ const totalWidth = Math.max(effectiveW + padX * 2, containerWidth);
614
+
615
+ // Build edge merge info
616
+ const edgeGroups = {}; // targetId -> [fromId, ...]
617
+ nodes.forEach(node => {
618
+ if (node.dependencies.length > 0) {
619
+ edgeGroups[node.id] = node.dependencies.filter(d => nodePositions[d]);
620
+ }
621
+ });
622
+
623
+ return { levels, nodePositions, edgeGroups, totalWidth, totalHeight, nodeW, nodes };
624
+ }, [workflow, containerWidth, measuredHeights]);
625
+
626
+ // Build member avatar map { id -> avatar } (must be before conditional returns to satisfy hooks rules)
627
+ const memberAvatarMap = useMemo(() => {
628
+ const map = {};
629
+ if (members?.length) {
630
+ members.forEach(m => { if (m.avatar) map[m.id] = m.avatar; });
631
+ }
632
+ return map;
633
+ }, [members]);
634
+
635
+ if (!workflow?.nodes?.length) {
636
+ // planning 阶段:展示负责人头像 + 气泡(表示正在拆解任务)
637
+ if (liveStatus?.currentAgentAvatar || liveStatus?.currentAgent) {
638
+ return (
639
+ <div className="flex items-start gap-3 px-6 py-8">
640
+ <div className="relative shrink-0">
641
+ {liveStatus.currentAgentAvatar ? (
642
+ <CachedAvatar src={liveStatus.currentAgentAvatar} alt="" className="w-10 h-10 rounded-full bg-[var(--border)]" />
643
+ ) : (
644
+ <div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-sm">🤖</div>
645
+ )}
646
+ <span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-500 rounded-full border-2 border-[var(--card)]" />
647
+ </div>
648
+ <div className="flex-1">
649
+ <div className="text-xs text-[var(--muted)] mb-1 font-medium">{liveStatus.currentAgent || t('reqDetail.workflow.leader')}</div>
650
+ <div className="inline-block bg-[var(--card)] border border-[var(--border)] rounded-2xl rounded-tl-sm px-4 py-3 text-sm">
651
+ <div className="flex items-center gap-2">
652
+ <span className="animate-pulse">🧠</span>
653
+ <span className="text-[var(--muted)]">{liveStatus.currentAction || t('reqDetail.workflow.planning')}</span>
654
+ </div>
655
+ <div className="flex items-center gap-1 mt-2">
656
+ <span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
657
+ <span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
658
+ <span className="w-1.5 h-1.5 bg-[var(--accent)] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
659
+ </div>
660
+ </div>
661
+ </div>
662
+ </div>
663
+ );
664
+ }
665
+ return (
666
+ <div className="flex items-center justify-center py-16 text-[var(--muted)]">
667
+ <div className="text-center">
668
+ <div className="text-4xl mb-2">📋</div>
669
+ <p>{t('reqDetail.workflow.notParsed')}</p>
670
+ </div>
671
+ </div>
672
+ );
673
+ }
674
+
675
+ if (!layout) return null;
676
+
677
+ const { nodePositions, edgeGroups, totalWidth, totalHeight, nodes } = layout;
678
+
679
+ const statusIcon = {
680
+ waiting: '⏳', ready: '🔵', running: '🔄', reviewing: '🔍', revision: '🔄', completed: '✅', failed: '❌',
681
+ };
682
+ const statusBorderColor = {
683
+ waiting: '#4b5563', ready: '#3b82f6', running: '#eab308', reviewing: '#8b5cf6', revision: '#f59e0b', completed: '#22c55e', failed: '#ef4444',
684
+ };
685
+ const statusColor = {
686
+ waiting: 'border-gray-600',
687
+ ready: 'border-blue-500',
688
+ running: 'border-yellow-500 animate-pulse',
689
+ reviewing: 'border-purple-500 animate-pulse',
690
+ revision: 'border-orange-500 animate-pulse',
691
+ completed: 'border-green-500',
692
+ failed: 'border-red-500',
693
+ };
694
+
695
+ // Card content render function (shared by measurement container and actual render)
696
+ const renderCardContent = (node) => {
697
+ const assigneeAvatar = memberAvatarMap[node.assigneeId];
698
+ const reviewerAvatar = memberAvatarMap[node.reviewerId];
699
+ return (
700
+ <>
701
+ <div className="flex items-start justify-between">
702
+ <div className="flex items-center gap-2 min-w-0">
703
+ <span className="text-base shrink-0">{statusIcon[node.status]}</span>
704
+ <div className="min-w-0">
705
+ <div className="font-medium text-sm truncate">{node.title}</div>
706
+ <div className="flex items-center gap-1 text-xs text-[var(--muted)]">
707
+ {assigneeAvatar ? (
708
+ <CachedAvatar src={assigneeAvatar} alt="" className="w-4 h-4 rounded-full inline-block" />
709
+ ) : (
710
+ <span className="w-4 h-4 rounded-full bg-gradient-to-br from-indigo-600 to-blue-700 flex items-center justify-center text-[8px] shrink-0">
711
+ {node.assigneeName?.charAt(0) || '?'}
712
+ </span>
713
+ )}
714
+ <span>{node.assigneeName}</span>
715
+ {node.reviewerName && (
716
+ <>
717
+ <span className="text-[var(--muted)]">·</span>
718
+ <span>🔍</span>
719
+ {reviewerAvatar ? (
720
+ <CachedAvatar src={reviewerAvatar} alt="" className="w-4 h-4 rounded-full inline-block" />
721
+ ) : (
722
+ <span className="w-4 h-4 rounded-full bg-gradient-to-br from-purple-600 to-pink-700 flex items-center justify-center text-[8px] shrink-0">
723
+ {node.reviewerName?.charAt(0) || '?'}
724
+ </span>
725
+ )}
726
+ <span>{node.reviewerName}</span>
727
+ </>
728
+ )}
729
+ </div>
730
+ </div>
731
+ </div>
732
+ {node.reviewRounds > 0 && (
733
+ <span className="text-[10px] bg-purple-500/20 text-purple-300 px-1.5 py-0.5 rounded shrink-0 ml-1">
734
+ R{node.reviewRounds}
735
+ </span>
736
+ )}
737
+ {node.completedAt && node.startedAt && (
738
+ <span className="text-[10px] text-[var(--muted)] shrink-0 ml-1">
739
+ {Math.round((new Date(node.completedAt) - new Date(node.startedAt)) / 1000)}s
740
+ </span>
741
+ )}
742
+ </div>
743
+ {node.description && (
744
+ <p className="text-xs text-[var(--muted)] mt-1.5 line-clamp-2">{node.description}</p>
745
+ )}
746
+ {/* Live action hint */}
747
+ {(node.status === 'running' || node.status === 'reviewing' || node.status === 'revision') && liveStatus?.currentNodeId === node.id && liveStatus.currentAction && (
748
+ <div className={`mt-1.5 rounded-lg px-2 py-1 text-[10px] flex items-center gap-1 overflow-hidden ${
749
+ node.status === 'reviewing' ? 'bg-purple-900/10 border border-purple-500/20 text-purple-300' :
750
+ node.status === 'revision' ? 'bg-orange-900/10 border border-orange-500/20 text-orange-300' :
751
+ 'bg-yellow-900/10 border border-yellow-500/20 text-yellow-300'
752
+ }`}>
753
+ <span className="animate-spin text-xs shrink-0">{node.status === 'reviewing' ? '🔍' : node.status === 'revision' ? '✏️' : '⚙️'}</span>
754
+ <span className="truncate">{liveStatus.currentAction}</span>
755
+ </div>
756
+ )}
757
+ {/* Tool call progress */}
758
+ {node.status === 'running' && liveStatus?.currentNodeId === node.id && liveStatus.toolCallsInProgress?.length > 0 && (
759
+ <div className="mt-1 flex gap-1 flex-wrap">
760
+ {liveStatus.toolCallsInProgress.slice(0, 3).map((tool, ti) => (
761
+ <span key={ti} className="text-[10px] bg-purple-900/30 text-purple-400 px-1.5 py-0.5 rounded animate-pulse">
762
+ 🔧 {tool}
763
+ </span>
764
+ ))}
765
+ </div>
766
+ )}
767
+ </>
768
+ );
769
+ };
770
+
771
+ // Generate connection paths: multiple incoming edges merge into one vertical line
772
+ const renderEdges = () => {
773
+ const paths = [];
774
+ const mergeGap = 25; // Distance from merge point to target node top
775
+
776
+ Object.entries(edgeGroups).forEach(([targetId, fromIds]) => {
777
+ const to = nodePositions[targetId];
778
+ if (!to) return;
779
+
780
+ const toCenterX = to.x + to.w / 2;
781
+ const toTopY = to.y;
782
+ const mergeY = toTopY - mergeGap; // Merge point Y coordinate
783
+
784
+ // Determine edge status color
785
+ const getEdgeStyle = (fromId) => {
786
+ const fromNode = nodePositions[fromId]?.node;
787
+ const toNode = to.node;
788
+ const bothDone = fromNode?.status === 'completed' && toNode?.status === 'completed';
789
+ const isActive = fromNode?.status === 'completed' && toNode?.status === 'running';
790
+ return {
791
+ color: bothDone ? '#22c55e' : isActive ? '#eab308' : '#4b5563',
792
+ width: 1,
793
+ isActive,
794
+ };
795
+ };
796
+
797
+ if (fromIds.length === 1) {
798
+ // Single incoming edge: direct Bezier curve connection
799
+ const fromId = fromIds[0];
800
+ const from = nodePositions[fromId];
801
+ if (!from) return;
802
+ const style = getEdgeStyle(fromId);
803
+ const x1 = from.x + from.w / 2;
804
+ const y1 = from.y + from.h;
805
+ const x2 = toCenterX;
806
+ const y2 = toTopY;
807
+ const cy = (y1 + y2) / 2;
808
+ paths.push(
809
+ <path
810
+ key={`edge-${fromId}-${targetId}`}
811
+ d={`M${x1},${y1} C${x1},${cy} ${x2},${cy} ${x2},${y2}`}
812
+ fill="none"
813
+ stroke={style.color}
814
+ strokeWidth={style.width}
815
+ strokeLinecap="round"
816
+ markerEnd={`url(#arrow-${style.color.replace('#', '')})`}
817
+ className={style.isActive ? 'edge-running' : ''}
818
+ />
819
+ );
820
+ } else {
821
+ // Multi-edge merge: all edges merge above target, then connect with one vertical line + arrow
822
+ // Use unified color to avoid inconsistency when edges overlap
823
+ const allStyles = fromIds.map(fid => getEdgeStyle(fid));
824
+ const bestStyle = allStyles.find(s => s.isActive) || allStyles.find(s => s.color === '#22c55e') || allStyles[0];
825
+
826
+ // 1. Merge vertical line: from mergeY to toTopY (unified color)
827
+ paths.push(
828
+ <path
829
+ key={`merge-vert-${targetId}`}
830
+ d={`M${toCenterX},${mergeY} L${toCenterX},${toTopY}`}
831
+ fill="none"
832
+ stroke={bestStyle.color}
833
+ strokeWidth={bestStyle.width}
834
+ strokeLinecap="round"
835
+ markerEnd={`url(#arrow-${bestStyle.color.replace('#', '')})`}
836
+ className={bestStyle.isActive ? 'edge-running' : ''}
837
+ />
838
+ );
839
+
840
+ // 2. Each incoming edge uses same color to avoid inconsistency when overlapping
841
+ // Sort incoming edges by X coordinate for clearer paths
842
+ const sortedFromIds = [...fromIds].sort((a, b) => {
843
+ const ax = nodePositions[a]?.x || 0;
844
+ const bx = nodePositions[b]?.x || 0;
845
+ return ax - bx;
846
+ });
847
+
848
+ sortedFromIds.forEach((fromId) => {
849
+ const from = nodePositions[fromId];
850
+ if (!from) return;
851
+ const x1 = from.x + from.w / 2;
852
+ const y1 = from.y + from.h;
853
+
854
+ if (Math.abs(x1 - toCenterX) < 2) {
855
+ // Nearly vertically aligned, direct vertical line to merge point
856
+ paths.push(
857
+ <path
858
+ key={`edge-${fromId}-${targetId}`}
859
+ d={`M${x1},${y1} L${x1},${mergeY}`}
860
+ fill="none"
861
+ stroke={bestStyle.color}
862
+ strokeWidth={bestStyle.width}
863
+ strokeLinecap="round"
864
+ className={bestStyle.isActive ? 'edge-running' : ''}
865
+ />
866
+ );
867
+ } else {
868
+ // From source bottom, first go down vertically, then smooth turn horizontally to merge point
869
+ const turnRadius = Math.min(15, Math.abs(x1 - toCenterX) / 2, (mergeY - y1) / 2);
870
+ const dir = toCenterX > x1 ? 1 : -1;
871
+
872
+ paths.push(
873
+ <path
874
+ key={`edge-${fromId}-${targetId}`}
875
+ d={`M${x1},${y1} L${x1},${mergeY - turnRadius} Q${x1},${mergeY} ${x1 + dir * turnRadius},${mergeY} L${toCenterX},${mergeY}`}
876
+ fill="none"
877
+ stroke={bestStyle.color}
878
+ strokeWidth={bestStyle.width}
879
+ strokeLinecap="round"
880
+ strokeLinejoin="round"
881
+ className={bestStyle.isActive ? 'edge-running' : ''}
882
+ />
883
+ );
884
+ }
885
+ });
886
+ }
887
+ });
888
+
889
+ return paths;
890
+ };
891
+
892
+ return (
893
+ <div className="p-6 space-y-4">
894
+ {workflow.summary && (
895
+ <div className="text-sm text-[var(--muted)] bg-white/5 rounded-lg p-3">
896
+ 💡 {workflow.summary}
897
+ </div>
898
+ )}
899
+
900
+ <div ref={containerRef} className="w-full overflow-x-auto">
901
+ <svg
902
+ ref={svgRef}
903
+ width={totalWidth}
904
+ height={totalHeight}
905
+ viewBox={`0 0 ${totalWidth} ${totalHeight}`}
906
+ className="select-none"
907
+ >
908
+ <defs>
909
+ {/* Arrow markers (by color) */}
910
+ {['4b5563', '22c55e', 'eab308', '3b82f6', 'ef4444'].map(hex => (
911
+ <marker key={hex} id={`arrow-${hex}`} viewBox="0 0 10 10" refX="9" refY="5"
912
+ markerWidth="6" markerHeight="6" orient="auto-start-reverse">
913
+ <path d="M1,1 L9,5 L1,9" fill="none" stroke={`#${hex}`} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
914
+ </marker>
915
+ ))}
916
+ {/* Flow animation */}
917
+ <style>{`
918
+ @keyframes dash-flow { to { stroke-dashoffset: -20; } }
919
+ .edge-running { stroke-dasharray: 8 4; animation: dash-flow 0.8s linear infinite; }
920
+ `}</style>
921
+ </defs>
922
+
923
+ {/* Connections */}
924
+ {renderEdges()}
925
+
926
+ {/* Node cards (foreignObject embedded div) */}
927
+ {Object.values(nodePositions).map(({ x, y, w, h, node }) => (
928
+ <foreignObject key={node.id} x={x} y={y} width={w} height={h}>
929
+ <div
930
+ xmlns="http://www.w3.org/1999/xhtml"
931
+ className={`bg-[var(--background)] border ${statusColor[node.status]} rounded-xl p-3 h-full overflow-hidden transition-all cursor-default`}
932
+ style={{ fontSize: '12px' }}
933
+ onMouseEnter={(e) => {
934
+ const rect = e.currentTarget.getBoundingClientRect();
935
+ setHoveredNode({ node, rect });
936
+ }}
937
+ onMouseLeave={() => setHoveredNode(null)}
938
+ >
939
+ {renderCardContent(node)}
940
+ </div>
941
+ </foreignObject>
942
+ ))}
943
+ </svg>
944
+ </div>
945
+
946
+ {/* Hidden measurement container: render all cards to get actual heights */}
947
+ <div
948
+ ref={measureRef}
949
+ aria-hidden="true"
950
+ style={{
951
+ position: 'absolute',
952
+ visibility: 'hidden',
953
+ pointerEvents: 'none',
954
+ width: layout.nodeW,
955
+ left: -9999,
956
+ top: 0,
957
+ }}
958
+ >
959
+ {nodes.map(node => (
960
+ <div
961
+ key={node.id}
962
+ data-node-id={node.id}
963
+ className={`bg-[var(--background)] border rounded-xl p-3`}
964
+ style={{ fontSize: '12px', width: layout.nodeW }}
965
+ >
966
+ {renderCardContent(node)}
967
+ </div>
968
+ ))}
969
+ </div>
970
+
971
+ {/* Hover Tooltip - floating layer showing full task content */}
972
+ {hoveredNode && (
973
+ <div
974
+ className="fixed z-[9999] pointer-events-none"
975
+ style={{
976
+ left: hoveredNode.rect.left + hoveredNode.rect.width / 2,
977
+ top: hoveredNode.rect.top - 8,
978
+ transform: 'translate(-50%, -100%)',
979
+ }}
980
+ >
981
+ <div className="bg-[#1a1a2e] border border-white/10 rounded-lg p-3 shadow-xl text-xs max-w-xs">
982
+ <div className="font-medium text-sm text-white mb-1">{hoveredNode.node.title}</div>
983
+ <div className="text-[var(--muted)] mb-1">👤 {hoveredNode.node.assigneeName}</div>
984
+ {hoveredNode.node.description && (
985
+ <p className="text-[var(--muted)] whitespace-pre-wrap break-words">{hoveredNode.node.description}</p>
986
+ )}
987
+ {hoveredNode.node.completedAt && hoveredNode.node.startedAt && (
988
+ <div className="mt-1 text-[var(--muted)]">{t('reqDetail.timeDuration', { n: Math.round((new Date(hoveredNode.node.completedAt) - new Date(hoveredNode.node.startedAt)) / 1000) })}</div>
989
+ )}
990
+ {(hoveredNode.node.status === 'running' || hoveredNode.node.status === 'reviewing' || hoveredNode.node.status === 'revision') && liveStatus?.currentNodeId === hoveredNode.node.id && liveStatus.currentAction && (
991
+ <div className={`mt-1 ${hoveredNode.node.status === 'reviewing' ? 'text-purple-300' : hoveredNode.node.status === 'revision' ? 'text-orange-300' : 'text-yellow-300'}`}>{hoveredNode.node.status === 'reviewing' ? '🔍' : hoveredNode.node.status === 'revision' ? '✏️' : '⚙️'} {liveStatus.currentAction}</div>
992
+ )}
993
+ {/* Arrow triangle */}
994
+ <div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-white/10" />
995
+ </div>
996
+ </div>
997
+ )}
998
+ </div>
999
+ );
1000
+ }
1001
+
1002
+
1003
+ /**
1004
+ * 心流偷看弹窗 — 从需求详情群员列表点🧠触发
1005
+ * 三个 Tab:工作日志(flow)、内心独白(thoughts)、历史心流(history)
1006
+ */
1007
+ function FlowPeekModal({ agentId, agentName, loading, tab, onTabChange, flowMsgs, monologueData, monologueThoughtMsgs, history, onClose }) {
1008
+ const { t } = useI18n();
1009
+ const cleanContent = (content) => {
1010
+ if (!content) return '';
1011
+ return content.replace(/^```[\s\S]*?```$/gm, t('reqDetail.flowPeek.codeBlock')).slice(0, 500);
1012
+ };
1013
+
1014
+ return (
1015
+ <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onClose}>
1016
+ <div className="w-full max-w-lg mx-4 bg-[var(--card)] border border-[var(--border)] rounded-2xl shadow-2xl max-h-[80vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
1017
+ <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)] bg-purple-900/20">
1018
+ <div className="flex items-center gap-2">
1019
+ <span className="text-lg">🧠</span>
1020
+ <span className="font-medium text-sm">{t('reqDetail.flowPeek.title', { name: agentName })}</span>
1021
+ </div>
1022
+ <button onClick={onClose} className="text-[var(--muted)] hover:text-white transition-colors text-lg">✕</button>
1023
+ </div>
1024
+
1025
+ <div className="flex border-b border-[var(--border)] px-4 bg-[var(--card)]">
1026
+ {[
1027
+ { id: 'thoughts', label: t('reqDetail.flowPeek.tabThoughts'), badge: monologueThoughtMsgs?.length || 0 },
1028
+ { id: 'flow', label: t('reqDetail.flowPeek.tabFlow'), badge: flowMsgs?.length || 0 },
1029
+ { id: 'history', label: t('reqDetail.flowPeek.tabHistory'), badge: history?.length || 0 },
1030
+ ].map(tb => (
1031
+ <button
1032
+ key={tb.id}
1033
+ onClick={() => onTabChange(tb.id)}
1034
+ className={`px-3 py-2 text-xs font-medium transition-all border-b-2 ${
1035
+ tab === tb.id
1036
+ ? 'border-purple-500 text-purple-300'
1037
+ : 'border-transparent text-[var(--muted)] hover:text-white'
1038
+ }`}
1039
+ >
1040
+ {tb.label}
1041
+ {tb.badge > 0 && (
1042
+ <span className="ml-1 text-[10px] bg-white/10 px-1.5 py-0.5 rounded-full">{tb.badge}</span>
1043
+ )}
1044
+ </button>
1045
+ ))}
1046
+ </div>
1047
+
1048
+ <div className="flex-1 overflow-auto p-4 space-y-3">
1049
+ {loading ? (
1050
+ <div className="flex items-center justify-center py-8">
1051
+ <span className="animate-spin text-2xl">🧠</span>
1052
+ <span className="ml-2 text-sm text-[var(--muted)]">{t('reqDetail.flowPeek.loading')}</span>
1053
+ </div>
1054
+ ) : tab === 'flow' ? (
1055
+ !flowMsgs?.length ? (
1056
+ <div className="text-center py-8 text-[var(--muted)] text-sm">
1057
+ <div className="text-3xl mb-2">📋</div>
1058
+ <p>{t('reqDetail.flowPeek.noFlowLogs')}</p>
1059
+ <p className="text-xs mt-1">{t('reqDetail.flowPeek.noFlowLogsHint')}</p>
1060
+ </div>
1061
+ ) : (
1062
+ flowMsgs.map((msg, i) => (
1063
+ <div key={msg.id || i} className={`rounded-xl p-3 text-sm ${
1064
+ msg.type === 'tool_call'
1065
+ ? 'bg-purple-900/20 border border-purple-500/10'
1066
+ : msg.type === 'output'
1067
+ ? 'bg-green-900/20 border border-green-500/10'
1068
+ : 'bg-white/5 border border-white/10'
1069
+ }`}>
1070
+ <div className="flex items-center justify-between mb-1">
1071
+ <span className="text-xs text-[var(--muted)]">
1072
+ {msg.type === 'tool_call' ? '🔧' : msg.type === 'output' ? '📄' : '💬'}{' '}
1073
+ {msg.time ? new Date(msg.time).toLocaleTimeString() : ''}
1074
+ </span>
1075
+ </div>
1076
+ <div className="text-sm break-words">{cleanContent(msg.content)}</div>
1077
+ </div>
1078
+ ))
1079
+ )
1080
+ ) : tab === 'thoughts' ? (
1081
+ monologueThoughtMsgs?.length > 0 ? (
1082
+ <div className="space-y-3">
1083
+ {/* 如果当前正在思考,显示状态提示 */}
1084
+ {monologueData?.status === 'thinking' && (
1085
+ <div className="flex items-center gap-2 mb-2">
1086
+ <span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-300 animate-pulse">
1087
+ {t('reqDetail.flowPeek.thinking')}
1088
+ </span>
1089
+ </div>
1090
+ )}
1091
+ {monologueThoughtMsgs.slice().reverse().map((msg, i) => (
1092
+ <div key={msg.id || i} className="bg-purple-900/20 border border-purple-500/10 rounded-xl p-3">
1093
+ <div className="flex items-center justify-between mb-1">
1094
+ <span className="text-xs text-purple-400">{t('systemSettings.monologue')}</span>
1095
+ <span className="text-xs text-[var(--muted)]">
1096
+ {msg.time ? new Date(msg.time).toLocaleTimeString() : ''}
1097
+ </span>
1098
+ </div>
1099
+ <div className="text-sm italic text-purple-200 whitespace-pre-wrap">
1100
+ {cleanContent(msg.content)}
1101
+ </div>
1102
+ </div>
1103
+ ))}
1104
+ </div>
1105
+ ) : (
1106
+ <div className="text-center py-8">
1107
+ <div className="text-3xl mb-2">😴</div>
1108
+ <p className="text-sm text-[var(--muted)]">{t('reqDetail.flowPeek.noMonologue')}</p>
1109
+ <p className="text-xs text-[var(--muted)] mt-1">{t('systemSettings.noMonologueYet')}</p>
1110
+ </div>
1111
+ )
1112
+ ) : (
1113
+ !history?.length ? (
1114
+ <div className="text-center py-8 text-[var(--muted)] text-sm">{t('reqDetail.flowPeek.noHistory')}</div>
1115
+ ) : (
1116
+ history.slice().reverse().map((m, i) => (
1117
+ <div key={m.id || i} className="bg-white/5 rounded-xl p-3 space-y-2">
1118
+ <div className="flex items-center justify-between">
1119
+ <span className="text-xs text-[var(--muted)]">
1120
+ {m.startedAt ? new Date(m.startedAt).toLocaleString() : ''}
1121
+ </span>
1122
+ <span className={`text-xs px-2 py-0.5 rounded-full ${
1123
+ m.decision === 'spoke' ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'
1124
+ }`}>
1125
+ {m.decision === 'spoke' ? t('reqDetail.flowPeek.spoke') : t('reqDetail.flowPeek.keptSilent')}
1126
+ </span>
1127
+ </div>
1128
+ {m.thoughts?.map((thought, ti) => (
1129
+ <div key={thought.id || ti} className="text-sm text-[var(--muted)] bg-black/20 rounded-lg p-2">
1130
+ {thought.content?.startsWith('[')
1131
+ ? <span className="text-green-400">{thought.content}</span>
1132
+ : <span className="italic">{thought.content}</span>
1133
+ }
1134
+ </div>
1135
+ ))}
1136
+ </div>
1137
+ ))
1138
+ )
1139
+ )}
1140
+ </div>
1141
+ </div>
1142
+ </div>
1143
+ );
1144
+ }
1145
+
1146
+
1147
+ /**
1148
+ * 群员列表 + 流程卡点面板
1149
+ * 展示参与此需求的所有 agent,以及当前流程卡在谁身上
1150
+ */
1151
+ function MembersAndBlockingPanel({ members, blockingInfo, workflow, status, onPeekFlow, onViewAgent }) {
1152
+ const { t } = useI18n();
1153
+ if (!members?.length && !blockingInfo?.length) return null;
1154
+
1155
+ const nodeStatusConfig = {
1156
+ running: { label: t('reqDetail.members.running'), color: 'text-yellow-400', bg: 'bg-yellow-900/20', icon: '⚡' },
1157
+ reviewing: { label: t('reqDetail.members.reviewing'), color: 'text-blue-400', bg: 'bg-blue-900/20', icon: '🔍' },
1158
+ revision: { label: t('reqDetail.members.revision'), color: 'text-orange-400', bg: 'bg-orange-900/20', icon: '🔄' },
1159
+ waiting: { label: t('reqDetail.members.waiting'), color: 'text-gray-400', bg: 'bg-gray-900/20', icon: '⏳' },
1160
+ ready: { label: t('reqDetail.members.ready'), color: 'text-cyan-400', bg: 'bg-cyan-900/20', icon: '🟢' },
1161
+ completed: { label: t('reqDetail.members.completed'), color: 'text-green-400', bg: 'bg-green-900/20', icon: '✅' },
1162
+ failed: { label: t('reqDetail.members.failed'), color: 'text-red-400', bg: 'bg-red-900/20', icon: '❌' },
1163
+ };
1164
+
1165
+ // 统计每个 agent 在此需求中的任务状态
1166
+ const agentTaskMap = {};
1167
+ if (workflow?.nodes) {
1168
+ for (const node of workflow.nodes) {
1169
+ if (node.assigneeId) {
1170
+ if (!agentTaskMap[node.assigneeId]) agentTaskMap[node.assigneeId] = [];
1171
+ agentTaskMap[node.assigneeId].push({ title: node.title, status: node.status });
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+ return (
1177
+ <div className="px-6 py-3 border-b border-white/[0.06] bg-[var(--card)]">
1178
+ {/* 流程卡点 */}
1179
+ {blockingInfo?.length > 0 && status === 'in_progress' && (
1180
+ <div className="mb-3">
1181
+ <div className="text-xs text-[var(--muted)] mb-1.5 font-medium">{t('reqDetail.members.blockingTitle')}</div>
1182
+ <div className="flex flex-wrap gap-2">
1183
+ {blockingInfo.map((b, i) => {
1184
+ const st = nodeStatusConfig[b.status] || nodeStatusConfig.running;
1185
+ return (
1186
+ <div key={b.nodeId || i} className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${st.bg} border-white/10`}>
1187
+ <span className="text-sm">{st.icon}</span>
1188
+ <div>
1189
+ <div className="text-xs font-medium">{b.nodeTitle}</div>
1190
+ <div className="text-[10px] text-[var(--muted)]">
1191
+ 👤 {b.assigneeName}
1192
+ {b.status === 'reviewing' && b.reviewerName && (
1193
+ <span className="ml-1">→ 🔍 {b.reviewerName}</span>
1194
+ )}
1195
+ <span className={`ml-1 ${st.color}`}>{st.label}</span>
1196
+ </div>
1197
+ </div>
1198
+ </div>
1199
+ );
1200
+ })}
1201
+ </div>
1202
+ </div>
1203
+ )}
1204
+
1205
+ {/* 群员列表 */}
1206
+ {members?.length > 0 && (
1207
+ <div>
1208
+ <div className="text-xs text-[var(--muted)] mb-1.5 font-medium">{t('reqDetail.members.title')} {t('reqDetail.members.count', { n: members.length })}</div>
1209
+ <div className="flex flex-wrap gap-2">
1210
+ {members.map(m => {
1211
+ const tasks = agentTaskMap[m.id] || [];
1212
+ const activeTasks = tasks.filter(t => ['running', 'reviewing', 'revision'].includes(t.status));
1213
+ const isActive = activeTasks.length > 0;
1214
+ const completedCount = tasks.filter(t => t.status === 'completed').length;
1215
+ return (
1216
+ <div
1217
+ key={m.id}
1218
+ className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg border transition-colors ${
1219
+ isActive
1220
+ ? 'bg-yellow-900/10 border-yellow-500/20'
1221
+ : 'bg-white/[0.02] border-white/[0.06] hover:bg-white/[0.04]'
1222
+ }`}
1223
+ title={tasks.length > 0 ? t('reqDetail.members.taskTooltip', { tasks: tasks.map(task => `${task.title}(${task.status})`).join(', ') }) : t('reqDetail.members.noTask')}
1224
+ >
1225
+ {/* 头像区域:上方🧠偷看心流,点击头像看卡片 */}
1226
+ <div className="relative group/avatar flex flex-col items-center">
1227
+ {/* 🧠 偷看心流按钮 — 悬浮头像时显示在上方 */}
1228
+ {onPeekFlow && (
1229
+ <button
1230
+ onClick={(e) => { e.stopPropagation(); onPeekFlow(m.id); }}
1231
+ className="absolute -top-3 left-1/2 -translate-x-1/2 opacity-0 group-hover/avatar:opacity-100 transition-all duration-200 text-[11px] hover:scale-125 z-10"
1232
+ title={t('reqDetail.members.peekFlow')}
1233
+ >
1234
+ 🧠
1235
+ </button>
1236
+ )}
1237
+ {/* 头像 — 点击看卡片 */}
1238
+ <button
1239
+ onClick={(e) => { e.stopPropagation(); onViewAgent?.(m.id); }}
1240
+ className="focus:outline-none hover:ring-2 hover:ring-purple-500/50 rounded-full transition-all"
1241
+ title={t('reqDetail.members.viewProfile')}
1242
+ >
1243
+ {m.avatar ? (
1244
+ <CachedAvatar src={m.avatar} alt="" className="w-7 h-7 rounded-full cursor-pointer" />
1245
+ ) : (
1246
+ <div className="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-600 to-blue-700 flex items-center justify-center text-[10px] cursor-pointer">
1247
+ {m.name?.charAt(0) || '?'}
1248
+ </div>
1249
+ )}
1250
+ </button>
1251
+ </div>
1252
+ <div className="min-w-0">
1253
+ <div className="text-xs font-medium truncate">{m.name}</div>
1254
+ <div className="text-[10px] text-[var(--muted)] truncate">
1255
+ {m.role}
1256
+ {isActive && (
1257
+ <span className="ml-1 text-yellow-400 animate-pulse">{t('reqDetail.members.working')}</span>
1258
+ )}
1259
+ {!isActive && completedCount > 0 && (
1260
+ <span className="ml-1 text-green-400">✅{completedCount}</span>
1261
+ )}
1262
+ {tasks.length === 0 && (
1263
+ <span className="ml-1 text-gray-500">{t('reqDetail.members.noTask')}</span>
1264
+ )}
1265
+ </div>
1266
+ </div>
1267
+ </div>
1268
+ );
1269
+ })}
1270
+ </div>
1271
+ </div>
1272
+ )}
1273
+ </div>
1274
+ );
1275
+ }
1276
+
1277
+
1278
+
1279
+